Skip to content

dash(embedded): E2d daemonless MN-set seed — pinned checkpoint + forward replay (#738)#899

Draft
frstrtr wants to merge 1 commit into
masterfrom
dash/e2d-payout-set-seed
Draft

dash(embedded): E2d daemonless MN-set seed — pinned checkpoint + forward replay (#738)#899
frstrtr wants to merge 1 commit into
masterfrom
dash/e2d-payout-set-seed

Conversation

@frstrtr

@frstrtr frstrtr commented Jul 26, 2026

Copy link
Copy Markdown
Owner

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 scriptPayout and nLastPaidHeight, and neither is committed in merkleRootMNList, so a payout-bearing set cannot be header-authenticated. The only cold-start source was dashd RPC protx list valid true (E2c, main_dash.cpp); the no-RPC arm printed seed UNAVAILABLE ... E2d follow-up and 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:

Where What it says
README.md → "DASH daemonless masternode-set checkpoint — trust anchor" Plain-language: what is trusted, what is verified, the trustless path as future work, fail-closed behaviour
KNOWN_ISSUES.md Listed as a security-model disclosure, incl. "shipped anchors are UNPINNED" and the staleness bound
CHANGELOG.md[Unreleased] Release-notes text, flagged with ⚠
src/impl/dash/coin/checkpoints/README.md Full provenance, the three-way source comparison table, re-pinning procedure, format spec
src/impl/dash/coin/mn_checkpoint.hpp Authoritative format + trust-boundary header comment
Runtime, on every load The banner below — not buried in source
[run] E2d MN-set checkpoint LOADED: 6 masternodes as-of h=1519543 (00000048ba417fd3)
      TRUST ANCHOR -- these masternode records are asserted by this release build, not derived from the chain.
      provenance: dashd testnet capture ... protx list valid true 1519543 ...
      generated:  2026-07-26T13:23:51Z
      digest:     eddc3386874eedd5f0e7619f198090390be13e9f5c9893512b629462a9953c91
      The anchor's chain position, its integrity digest and every replayed block's coinbase ARE verified locally;
      the set contents at h=1519543 are NOT (no consensus commitment exists). See src/impl/dash/coin/checkpoints/README.md.
      bridging forward to the tip (max 20000 blocks) before the embedded arm may serve.

What is still verified locally, with no trust

  • Chain position — the anchor's blockhash must equal what our own X11-PoW + DGW-validated HeaderChain holds at height. Wrong chain / wrong fork → refused.
  • Integrity — SHA-256 over every non-comment, non-digest line. 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.
  • Forward consistency — every replayed block re-derives the projected DIP-3 payee from the anchored set and compares it with that block's real coinbase. A wrong anchor is falsified within a few blocks and the bridge fails closed permanently. The anchor is trusted at height and 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 in mn_checkpoint.hpp; summary in checkpoints/README.md.

c2pool-dash-mn-checkpoint/1
network / height / blockhash / source / generated / count / digest
mn <proTxHash> <collateralHash> <collateralIndex> <type> <version> <registeredHeight>
   <lastPaidHeight> <poseRevivedHeight> <poseBanHeight> <consecutivePayments>
   <revocationReason> <operatorReward> <scriptPayout> <scriptOperatorPayout|->
   <keyIDOwner|-> <keyIDVoting|-> <pubKeyOperator|->

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:

tools/dash/gen_mn_checkpoint.py pin --network mainnet --rpc-url ... --rpc-user U --rpc-password P
tools/dash/gen_mn_checkpoint.py verify src/impl/dash/coin/checkpoints/dash_mn_checkpoint_mainnet.inc

pin brackets protx list valid true <h> with getblockcount before/after and refetches on a mid-flight tip move (a mislabelled height seeds the payment cursor wrong = served bad-cb-payee), refuses if getblockchaininfo.chain disagrees with --network, converts payoutAddress → scriptPayout once at release time, sorts by proTxHash for readable re-pin diffs, and prints a provenance block for the release notes. verify is a release-checklist / post-rebase step; on an unpinned file it says UNPINNED and 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 .inc files 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 real protx list valid true 1519543, replayed over the real accepted blocks 1519544–1519546.


Wiring

Piece File
Format + fail-closed parser + digest rule src/impl/dash/coin/mn_checkpoint.hpp (new)
Bridge (private replay machine, verification, publish) src/impl/dash/coin/mn_checkpoint_lane.hpp (new)
Anchors + provenance doc src/impl/dash/coin/checkpoints/ (new)
Release-time generator tools/dash/gen_mn_checkpoint.py (new)
Arming, seams, banners, --embedded-mn-bridge-max src/c2pool/main_dash.cpp
Anti-mint latch src/impl/dash/coin/coin_state_maintainer.hpp

Why the bridge runs on a PRIVATE MnStateMachine rather than publishing the anchor and letting the maintainer fold forward — the obvious wiring is actively dangerous. apply_block is forward-contiguous: after loading a snapshot at h, the only block it may fold is h+1. During a cold start the live feed is simultaneously delivering blocks at the current tip (the new_block inv → request_block leg). Publishing at h=CKPT would arm MN-readiness immediately against a payment queue thousands of blocks stale — a served bad-cb-payee window — and then the first live tip block would trip gap_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 a ProRegTx registers one masternode, size() != 0 flips m_have_mn, and with the tip half live populated() 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_dash opts the embedded arm in.

The RPC path is untouched. When a coin RPC is configured, protx list is authoritative and the checkpoint is never consulted.


Fail-closed behaviour, and how it was proven

Condition Result Test
Anchor unpinned ERROR banner, terminal, no serve UnpinnedPayloadIsReportedDistinctly, RefusedCheckpointArmsTerminallyFailedClosed
Digest mismatch (hand edit / half merge) whole checkpoint refused, no partial set DigestMismatchRefusesEverything
Wrong network / bad magic / unknown key / count mismatch / missing scriptPayout / duplicate proTxHash / -1 / 12abc / all-zero blockhash refused 8 dedicated cases
Chain position ≠ our header chain terminal fail-closed, never resurrects ChainPositionMismatchFailsClosed
Anchor staler than the bridge bound terminal fail-closed, no block bodies requested StaleAnchorBeyondBridgeBoundFailsClosed
Replay gap never publishes ReplayGapFailsClosed
Replay payee-desync (anchor falsified by a real coinbase) terminal fail-closed PayeeDesyncOnWrongAnchorFailsClosed
Headers not yet at the anchor waits quietly, no publish, no requests WaitsQuietlyUntilHeadersReachTheAnchor

Live proof of the fail-closed banner (real binary, --embedded-mainnet, no RPC, no anchor):

  ============================================
  E2d MN-set CHECKPOINT REFUSED -- FAIL CLOSED
  ============================================
  checkpoint is UNPINNED: this build carries no DASH masternode-set anchor ...
  The embedded DASH arm will NOT serve templates.
  No masternode payee will be guessed.
  Configure a dashd RPC (--coin-rpc-*) or run a
  release that carries a testnet masternode-set 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:

  1. mn_ckpt_lane->pump() inside header_chain->set_on_tip_changed(...). pump() calls HeaderChain::height() and get_header_by_height(), both of which take HeaderChain::m_mutex. HeaderChain dispatches m_on_tip_changed with that mutex released: add_header() and add_headers() each copy the pending tip-change out inside a lock_guard scope, close the scope, and only then invoke the callback (header_chain.hpp — the two m_on_tip_changed(...) call sites sit outside every lock_guard block). Independent confirmation: the pre-existing line two below mine in the same lambda already calls tip_advance_from_chain(*hc, ...), which takes the same lock, and that path is proven live in production.

  2. mn_ckpt_lane->on_block_connected(...) from the block_connected subscription. Fires from the live-feed bridge's full_block handler 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 as UtxoLane).

Reachability is also proven empirically, not just by reading: DashMnCheckpointBridge.ReplaysRealBlocksAndPublishesAtTip drives pump()request_block_fnon_block_connected → publish and asserts the published set, and DashMnCheckpointE2e.BridgePublishFlipsPopulatedThroughTheRealLeg4Event carries that publish through the real wire_mn_list_ingest subscription until populated() flips. If the callees were dead, both would fail.

I also found and fixed a self-inflicted variant during testing: request_window() originally set m_status after 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 longer Bridging.


Test evidence (real output)

Folded into the existing allowlisted test_dash_node_reception_wire target — never a new add_executable (silent "Not Run"). Per #895: no #ifdef guards anywhere in the new TU, so every registered case genuinely executes; the counts below move if a case is skipped.

$ ./test/test_dash_node_reception_wire --gtest_filter='DashMnCheckpoint*'
[==========] 24 tests from 3 test suites ran. (2 ms total)
[  PASSED  ] 24 tests.

No regressions in the suites the maintainer change can reach:

test_dash_coin_state_maintainer   PASSED 15
test_dash_node_coin_state         PASSED 16
test_dash_get_work                PASSED  4
test_dash_node_embedded_wire      PASSED  3
test_dash_mn_state                PASSED 30
test_dash_node_reception_wire     PASSED 46   (was 22 + 24 new)
test_dash_work_source             PASSED  9
test_dash_superblock              PASSED 24

c2pool-dash builds clean; python3 tools/ci/check_test_target_allowlist.pyCI drift-guard OK: 121 per-coin test target(s) ... all present.

Negative controls

  1. 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.
  2. 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).
  3. PayeeDesyncOnWrongAnchorFailsClosed — a structurally valid but semantically wrong anchor (one lastPaidHeight moved to the top of the queue) replayed over a real block 1519544; the real coinbase falsifies it and the bridge never publishes.
  4. StaleAnchorBeyondBridgeBoundFailsClosed asserts requested.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:

dc2e02ac... nLastPaidHeight == 1519544
9b653e76... nLastPaidHeight == 1519545
91bbce94... nLastPaidHeight == 1519546

And CheckpointSetIsFieldIdenticalToRpcSeed asserts the checkpoint-derived MNStates are field-identical to what parse_protx_list_seed produces from the same dashd JSON — the two seed paths cannot diverge silently.


What I could not verify

  • No mainnet/testnet anchor is pinned (no reachable synced dashd). The daemonless mainnet cold start is therefore proven only up to "fails closed loudly with no anchor" plus the full bridge mechanics against real testnet data. Pinning + a live daemonless mainnet soak is the follow-up before this is enabled anywhere.
  • No live long-bridge run. The 20000-block bound, the windowed request pacing (kWindow = 64) and the stall re-request are reasoned + unit-tested, not soaked. Per the repo's own EMPIRICAL SOAK > review rule, treat those numbers as unproven until a real bridge runs.
  • Reorg during a bridge is not specially handled: a reorg that changes the anchor's chain position would be caught by the position check only at the first verification, not re-checked mid-bridge. Today the bridge is short and the maintainer's reorg path wipes/demotes independently, but this is a known gap worth its own slice.

Coordination

Touches src/c2pool/main_dash.cpp but 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-RPC else branch, 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, coin send_shares/broadcast_share, .github/workflows/.

Draft — do not merge. Wants review of the trust-anchor disclosure wording as much as the code.

…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.
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