dash(daemonless): combined fetch+fold onion — THE dashd-cut landing branch - #1293
Draft
frstrtr wants to merge 20 commits into
Draft
dash(daemonless): combined fetch+fold onion — THE dashd-cut landing branch#1293frstrtr wants to merge 20 commits into
frstrtr wants to merge 20 commits into
Conversation
…eplayMNState New MnDiffStore (src/impl/dash/coin/mn_diff_store.hpp), the c2pool port of dashd's evodb list-diff mechanism (evo/deterministicmns.cpp:689-870): * 'D'+blockHash per-block diff records (DB_LIST_DIFF dmn_D4 analogue), written for EVERY block including empty diffs (proof-of-processing); no height stored, the walker stamps height from the header-chain walk (deterministicmns.cpp:697/:822 rule kept verbatim) * 'S'+blockHash full snapshots, value = the fold engine's snapshot-v3 codec unchanged; 576-block cadence (DISK_SNAPSHOT_PERIOD) * 'B' best-block sentinel written in the same batch as every 'D' (EVODB_BEST_BLOCK posture, validation.cpp:2322) * 'F' format key with wipe-WHOLE-STORE on mismatch (the dmn_D3->D4 migration idiom; never per-entry migration) * MnStateDiff: CDeterministicMNStateDiff's 19-bit field mask, member order and nVersion-first rule (evo/dmnstate.h:155-260) * MnListDiff: BuildDiff/ApplyDiff (deterministicmns.cpp:361-418) with exception->refusal; internalId-sorted adds, removal-scan arithmetic, and every AddMN uniqueness throw as a fail-closed refusal * reconstruct(): the GetListForBlockInternal backward-walk + forward replay as a cold repair path, with one deliberate inversion: a missing row is REPAIR-REFUSED, never dashd's initial-empty-list default -- our store is fed by a P2P fold and can transiently miss rows * per-record proof flags (root_matched, payee_verified), committed merkleRootMNList, prev_hash and a sha256d trailer; the walk cross-checks prev at every hop against the PoW-validated header chain, requires both flags on the whole range, and verifies the reconstructed list re-hashes to the committed root before trusting it * startup verify: 'B' sentinel cross-check + newest-snapshot-pair re-derivation (VerifySnapshotPair analogue, scope-reduced); verify-then-wipe, never verify-then-patch 17 KATs in test/test_dash_mn_diff_store.cpp: bitmask masking, the nVersion rule, build/apply round trips, empty-diff records, digest corruption, all five apply refusals, the snapshot+diff walk, the missing-row and flag-hole refusals, orphaned-sibling disambiguation, prev-hash cross-check, final-root mismatch, format wipe, sentinel and pair verifies, depth bound, off-chain target.
…ayee-queue gaps from it; consumer currency gate Write side (the dashd ProcessBlock store-write analogue, evo/deterministicmns.cpp:689-694): * FoldReplayConsumer gains a diff sink invoked only after a fold completed (root self-check passed); MnDiffWriter builds the oldList->newList diff, stamps the proof flags from the FoldResult and commits 'D' (+ cadence 'S') + 'B' in one synced batch. The writer disarms on any anomaly -- a hole in the store refuses at read time, it never fakes continuity. arm() writes the seed/anchor snapshot (dashd's extra initial-snapshot-block row) that grounds every backward walk. * main_dash opens dash_mn_diff_db next to dash_replay_headers, runs the startup sentinel + newest-pair verify (wipe THIS store alone on failure; the live fold repopulates), and deliberately does NOT wire the store into the reorg wipe cascade -- dashd's UndoBlock keeps disk rows too (deterministicmns.cpp:736-769), and wiping diff history on reorg would destroy exactly what repair depends on. Gap repair (mn_gap_repair.hpp seam, wired at BOTH gap handlers): * CoinStateMaintainer::on_block_connected: on APPLY GAP at block H, reconstruct the verified list at H-1 from the store, reseed through the EXISTING on_mn_list_update sink (source=mn-diff-repair; the anti-mint invariant is respected, not bypassed) and re-apply the held block; pass-3's coinbase cross-check is the final tripwire. ANY refusal falls through to the unchanged wipe/demote/reseed chain -- repair only adds a lane in front, it removes nothing. * MnCheckpointLane::on_block_connected: same retry in front of the previously-terminal fail_closed. * lane_diag: mn-diff-repair is a named, daemonless source (its rows are fold outputs written only after the engine's own root+payee self-checks). Consumer currency gate (the enforcement half of the G7 split, landed BEFORE the publisher relaxation on purpose): on_mn_list_update now compares the two operands it always held -- the seed's as_of height and serve_tip_height() -- and refuses to arm SERVING off a below-tip seed unless the diff-store catch-up brings the queue cursor to the tip. The seed itself is stored; a refusal costs nothing and the incumbent lane keeps its job. Test updated: the anti-mint positive control now stamps at the serve tip, and the gate gets its own negative control (stored-but-not-armed, no wipe, no reseed latch).
…olds the currency gate The publisher half of the G7 split, landed LAST (after the diff store, the gap repair and the consumer-side gate) because this ordering is load-bearing: relaxing G7 with nothing to catch the below-tip window is exactly the latch-eternal failure shape. G7 in replay_payee_publish.hpp (the ONLY guard in scope -- G1-G6, G8, G9 untouched) now withholds when fold_height + kPublishEpsilon < tip_height instead of demanding exact tip equality. kPublishEpsilon=2: a design bound from engine-tail-lag (seconds) vs the 2.5-minute block interval, NOT measured from soak data -- re-derive from the live tail-lag distribution before widening. tip==0 still withholds; the blocker keeps the G7 2-char prefix the withheld-log dedup keys on. Within epsilon the publish carries as_of = fold_height (the stamp already existed) and the consumer's currency gate holds serving until the queue cursor reaches the tip from stored root+payee-verified diffs, so no serve ever projects a below-tip queue front. KATs: boundary refusal one past epsilon, publish AT the epsilon boundary stamped as_of the fold height, exact-tip publish unchanged, far-behind and unknown-tip refusals unchanged.
…y gap repaired byte-identical to a contiguous fold; fail-closed floor and G1-G9 fence pinned
The store block in main_dash.cpp (MnDiffStore ctor, MnDiffWriter, arm(), set_diff_sink, repair_fn, mn_ckpt_lane->set_gap_repair) was gated under replay_fold_consumer -> g_replay_bulk -> --replay-fold-prestate, so it was dormant on the daemonless cold flags. Lift the store+writer construction onto the cold MN-CKPT bridge (guard mn_ckpt_lane!=null && header_chain!=null). The writer binds a DmlFoldEngine (ReplayMNState); the cold bridge folds MnStateMachine (MNState). Stand up a PARALLEL DmlFoldEngine seeded at the bridge anchor, fed each replayed block from mn_checkpoint_lane on the block-applied hook right after m_machine.apply_block succeeds, and sink MnDiffWriter::on_folded off THAT engine root+payee-checked commit. Source half (mn_checkpoint_lane.hpp begin_revive_probe and begin_ondemand_fold): before a capped network probe, call m_gap_repair(h-1)/m_gap_repair(h) to reconstruct the height-exact list from the store 0-RTT, load it via m_machine.load + mark_ban_state_measured(h) so ban_state_measured_for(h) holds; only fall through to begin_fold/m_request_snapshot when the store is absent or refuses. #1258 repair_unmeasured_revive_ordering folded in as targeted hunks (belt-and-suspenders) with the payee_desync fail-closed last. Reward-safety is by construction: MnDiffStore::reconstruct REFUSES any row not root_matched/payee_verified against the block committed merkleRootMNList. A wrong arm or an incomplete seed never mints -- it fail-closes. NOTE (cold-anchor limitation, documented inline): the cold MN-CKPT anchor is a payee-only checkpoint (omits confirmedHash/netInfo), so the parallel engine cannot reproduce the anchor+1 merkleRootMNList and poisons at the first fold. The store then holds only its anchor row and every height above repair-refuses (falls through to the network unchanged). The store-accelerated 0-RTT path becomes effective only when a full-state v3 anchor is pinned.
…hor SML The parallel DmlFoldEngine on the cold MN-CKPT bridge was seeded from the compiled payee-only checkpoint, which omits the two fields the anchor block committed merkleRootMNList commits to (confirmedHash, netInfo). It therefore could not reproduce the anchor+1 root and POISONED at the first fold, so the MN diff-store covered 0 heights and the ban-state probe / on-demand fold cap exhausted -> cold bridge fail-closed with ProUpServTx left unmeasured. Seed the engine from the FULL Simplified MN List instead. The lane already fetches getmnlistd(base=ZERO, target=anchor_hash) as its own anchor fold and DIP-4-authenticates the reply against the anchor block committed merkleRootMNList (historical_sml.hpp authenticate_historical_snapshot -- no partial acceptance). set_on_anchor_snapshot delivers that verified full SML to main_dash, which MERGEs its root-committing fields (confirmedHash, netInfo) onto the checkpoint forward-fold facets (collateralOutpoint, nRegisteredHeight, nLastPaidHeight, poseBan/Revived, keyIDOwner, scriptPayout ...) the SML does not carry, seeds the engine, and SELF-CHECKS that the merged state reproduces the anchor committed root before arming forward folds. Reward-safe by construction: a seed that fails the self-check leaves the engine UNSEEDED, the store never populates, and every SOURCE caller falls through to the unchanged network path -- never a wrong row, never a bad mint. The seam reuses the existing getmnlistd primitive, the historical demux, the DIP-4 authenticator and DmlFoldEngine::seed verbatim; no new primitive, no cap widen.
…lel fold engine The cold MN-CKPT bridge stands up a parallel DmlFoldEngine (full ReplayMNState) to write the MN diff/snapshot store, seeded at the full-state anchor. It was created WITHOUT a quorum-member resolver, so its fold FAILED CLOSED at the first punishing qfcommit (llmqType=5 @ h=2513130, ~130 past the anchor): the PoSe-punish loop had no ordered member list to index against the commitment validMembers bitset and returned "no quorum-member resolver is installed -- PoSe punishes cannot be folded, failing closed". The store therefore covered only ~130 heights and the ban-state probe / on-demand fold fell back to the capped network getmnlistd path (payee-desync). Mirror the main replay-fold path exactly: stand up a second QuorumReplayEngine seeded at the same anchor and a second ReplayQuorumBridge whose ctor installs set_members_fn onto the store engine, seed the pre-anchor header hashes, prime_at_anchor after the deferred full-state seed lands, and drive observe()/after_fold() from the store block feed around each fold_block. The engine self-derives rotated + non-rotated quorum member sets from the same replayed blocks (dashd CalcQuorumMembers/GetAllQuorumMembers analog), self-checked against each block committed merkleRootQuorums, with no qrinfo/P2P dependency. The already-ported HandleQuorumCommitment PoSe-punish loop then applies the dashd-exact punishes so the folded list re-hashes to the committed merkleRootMNList past the qfcommit and the store covers heights to tip. Reward-safe by construction: a wrong member set / wrong penalty makes the folded root diverge from the block committed merkleRootMNList, the fold poisons (HARD STOP), the writer never sinks the row, the store stays empty, and callers fall through to the network path -- never a bad mint. Fail-closed stays the last resort.
…nchor via getqrinfo The cold MN diff-store second QuorumReplayEngine is seeded at the anchor with zero pre-anchor quarter snapshots (only pre-anchor header hashes). The first rotated (DIP-24, llmqType=5) cycle mined after the anchor has a base B0 whose three quarter predecessors (B0-C/2C/3C) all sit below the anchor, so self_contained_from is beyond it and the engine cannot self-derive that cycle from the replayed post-anchor stream. Its first punishing qfcommit then hits fold_qfcommit with "resolver has no member set", the parallel fold fails closed a window past the anchor, the store caps, and the MN-CKPT payee lane falls to the capped network probe (the payee-desync fail-closed wedge). Port the dashd DIP-24 rotated-quorum bootstrap (CGetQuorumRotationInfo / BuildQuorumSnapshot), the companion to the full-state anchor SML seed: at cold store-arm fetch getqrinfo(B0) once via the existing qrinfo primitive, DIP-4/merkle authenticate the returned quarter snapshots and work-block MN lists against the committed roots (authenticate_historical_snapshot), and seed them into the store engine through the bridge seed_snapshots / seed_work_lists guards so cycle B0 becomes derivable. An ordering gate holds straddle blocks until the seed lands (then drains them in order), re-issues the fetch while holding, and — if the reply never arrives within a bounded hold — caps the store below the straddle and lets callers fall through unchanged rather than poison. Reward-safe by construction: a wrong or partial snapshot yields wrong members, a folded-list root mismatch, the writer refuses the row, the store stays empty, and callers use the network path — never a bad mint. Reuses the operator store/engines verbatim; adds only the seed transport.
…enticate The #1263 getqrinfo straddle seed is fully wired but never lands on a cold fast-start node: the three rotated quarter snapshots for the straddle cycle sit at WORK blocks below the fast-start anchor (2513000), and the cold header chain holds only the lone anchor. DIP-4 leg (b) of authenticate_historical_ snapshot binds the snapshot cbTx merkle proof to OUR PoW-verified headers
…rinfo seed
The W4 store-engine quorum resolver seeded the straddle cycle (2513088, type 5)
qrinfo quarters but never drove ComputeQuorumMembersByQuarterRotation to a KEYED
result for it, so fold_qfcommit at cycle_base+mining_window_start (h=2513130)
got no member set and failed closed, capping the parallel diff store ~130
heights past the anchor and forcing the bridge onto the capped network payee
lane (the h=2517703 PAYEE DESYNC class).
Add QuorumReplayEngine::derive_members_for_cycle(type, cycle_base): it drives
the engine's own compute_rotation_cycle over the just-seeded quarters plus the
self-derived new quarter and registers the ordered member set under
m_members[{type, cycle_base+quorumIndex}] -- exactly the key fold_qfcommit's
m_members_fn(type, quorumHash) resolves a commitment quorumHash to. The keying
is dashd-correct: GetQuorumMembers derives the cycle base by
GetAncestor(nHeight - quorumIndex), so the commitment quorumHash's block sits at
height cycle_base+quorumIndex. ReplayQuorumBridge exposes a pass-through;
main_dash invokes it the moment the getqrinfo straddle seed lands, BEFORE the
held blocks drain, so the qfcommit resolves and the fold folds through toward
tip.
Reuses the ported rotation math verbatim (no new consensus code). Reward-safe:
a wrong member set re-hashes to the wrong merkleRootMNList and the writer's
per-row root self-check refuses the row -> callers fall through to the network
path, never a bad mint.
KAT-D (test_dash_replay_quorum_engine): GREEN assembles 32/32 dashd-exact
ordered sets resolvable through members_for; RED (a missing quarter) names the
skip and leaves the fold with no member set (fail-closed); a tampered quarter
produces a detectably-wrong set (the divergence the root self-check catches).
Serialize a --replay-bulk DML fold's REGISTERED masternode set at a target height H to the checkpoint .inc payload (mn_checkpoint.hpp), SELF-DERIVED from the fold's own root-checked ReplayMNState -- no dashd RPC, no trusted protx snapshot (operator decision 2026-08-17). scriptPayout is taken verbatim from the ProRegTx/ProUpRegTx the fold replayed; nLastPaidHeight from the payee bookkeeping the fold already keeps (dashd BuildNewListFromBlock parity). Reward-safety is by REUSE: the 17-field order + conversions are the same the runtime parser reads and gen_mn_checkpoint.py writes; the digest is the shared mn_checkpoint_digest(); write_mn_checkpoint_inc() re-parses its own output through parse_mn_checkpoint() before writing, so it can never emit a file the runtime cold-start would refuse. REGISTERED, not valid- filtered: PoSe-banned MNs are emitted carrying poseBanHeight (isValid is re-derived as poseBanHeight==0). Load-bearing byte-order split preserved: proTxHash/collateralHash via GetHex (display), keyIDOwner/keyIDVoting via forward LE-limb bytes (matching hex_to_uint160), lines sorted by proTxHash display hex (matching the generator, NOT std::map internal order). - new src/impl/dash/coin/mn_checkpoint_dump.hpp: emit_mn_checkpoint_dump / emit_mn_record_line / write_mn_checkpoint_inc - replay_fold_consumer.hpp: one-shot set_dump_hook(H, fn), fired after the successful fold whose cursor first reaches H (separate from the W4 post_fold seam so both coexist) - main_dash.cpp: --dump-mn-checkpoint H FILE arms the hook on the --replay-fold consumer path; source line names the self-derived provenance - test_dash_mn_checkpoint.cpp: DashMnCheckpointDump round-trip KAT -- a replayed set dumps and parses back FIELD-IDENTICAL through parse_mn_checkpoint() (mirror of CheckpointSetIsFieldIdenticalToRpcSeed), plus sort-order, determinism, digest, .inc file, and fail-closed (empty / payee-less) cases Build: C2POOL_DASH_BLS=REAL. 173/173 in the checkpoint test target green.
…t (true from-chain self-derive) A true from-DIP3 self-derive derives the fresh MN-checkpoint set from chain (dashd BuildNewListFromBlock parity) instead of capturing it from dashd RPC. The only missing piece was the anchor seed: the W1 fold must start from an EMPTY masternode set at the DIP3 activation height (h=1028160). Empty is chain-true there -- DASH's deterministic MN list is empty until the first ProRegTx at/after DIP3 enforcement -- but the prestate loader rejected EVERY empty entries set. replay_prestate.hpp: allow an empty entries set ONLY when the prestate height equals the DIP3 activation height, reusing the SAME constant --replay-bulk-start defaults to (rp::MAINNET_DIP3_HEIGHT). At any other height an empty set stays a hard reject. Reward-safe and fail-closed: the empty seed is never trusted on faith -- the fold's per-block merkleRootMNList self-check (replay_fold_engine poison()) hard-stops at the first post-DIP3 ProRegTx block if the seed is wrong, so it can never mint. DIP-4 / root self-checks stay strict. test_dash_replay_prestate_empty_dip3.cpp: KAT for the loader gate -- empty at DIP3 ACCEPTED, empty at any other height (below/above/DIP3+1) REJECTED, count mismatch still fails, and the empty DIP3 seed reproduces the empty-list merkleRootMNList (uint256::ZERO) through the same seed_engine_from_prestate root self-check a live replay trusts. Folded into the already-allowlisted test_dash_mn_state target so it is built + run in CI.
…exclusion) The self-derive replay (--replay-bulk + --replay-fold-prestate + a compiled fast-start checkpoint) SIGSEGV/SIGBUS'd on the first historical mnlistdiff, in DmlFoldEngine::save_snapshot() <- MnDiffWriter::arm() <- the cold MN-CKPT bridge set_on_anchor_snapshot lambda. Root cause: the cold MN-CKPT store-bridge (main_dash.cpp ~6182) and the W5 replay-fold store-arm (~7288) both drive the single mn_diff_store/ mn_diff_writer unique_ptrs. They are mutually exclusive BY DESIGN (the cold bridge is documented as the path with no --replay-bulk / --replay-fold- prestate), but the guard never enforced it: the cold bridge arms first, creates writer A, and installs an on-anchor-snapshot lambda that captures a raw w = mn_diff_writer.get(). The later W5 make_unique then frees writer A behind that lambda's back; the historical-snapshot callback dereferences the freed writer -> UAF. Fix: gate the cold bridge arm on g_replay_fold_prestate.empty() so a deep replay-fold owns the single store cleanly (the cold bridge yields, dashd- parity: dashd builds evoDB from genesis with no such checkpoint bridge). Add a defensive named fail-closed at the W5 arm: if the store is somehow already present, refuse to clobber the live writer (fail closed with a named error, never a segfault) and fold without the accelerator store (reward-safe: the store is a gap-repair accelerator, never consensus-required). Normal cold start (no prestate) is UNCHANGED: the gate predicate is true there and the cold bridge arms exactly as before.
…ent-refill into the bulk block-fetch lane The full-history --replay-bulk lane measured timeout=753956 against 1.68M delivered (a 45% re-request rate) with notfound=0 across the whole 2.52M-block run: non-archival peers do not DECLINE deep-history getdata, they SILENTLY DROP it, and a near-dead peer kept its 32-slot queue the entire run because nothing demoted it. The in-order delivery cursor head-of-line-blocked behind each hole for the flat 30 s request_timeout, turning ~39 h of the 51.5 h wall into stall bands (2.3 blk/s and below) while healthy bands ran 75-107 blk/s (BEAT dashd's ~58). Run average 13.6 blk/s. This ports dashd net_processing's block-download peer policy into BulkBlockScheduler::pump() / BulkFetchLane, THROUGHPUT-ONLY. 1. CanServeBlocks service-bit + coverage filter (open task #148, reuses the #1254 helper). The lane's eligible_peers seam now returns CoinClient::eligible_bulk_peer_keys() — the handshaked pool filtered to archival deliverers (advertises NODE_NETWORK, not stall-demoted), primary excluded unless sole survivor. pump() takes a peer_can_serve(peer,height) predicate wired to bulk_peer_can_serve() (CanServeBlocks + peer_covers_height start_height coverage); a contiguous range is assigned to a peer only if it advertises it holds that history. Cites dashd FindNextBlocksToDownload / CanServeBlocks. This is the proactive half of the policy. 2. Stall-disconnect/demote (dashd BLOCK_STALLING_TIMEOUT). A peer that leaves demote_after(=3) consecutive getdata unanswered is held OFF fresh contiguous-range assignment for demote_cooldown_sec(=60); a single delivered body clears the streak and the demotion. The head-of-line range re-homes to a serving peer on the next pump rather than after the flat 30 s. Retries are never gated (the oldest missing height still lands on whichever slot is free; the per-height avoid-peer steers it off the last failer), and if every peer is demoted the gate is bypassed for liveness so the fetch never freezes. This is the reactive half. demote_after=0 restores byte-identical pre-port behaviour. 3. Event-driven refill. A delivered body now event-drives an immediate issue_bulk_pump() (guarded by the same strict-priority checks as tick, and by the now_sec clock seam) instead of waiting up to one full 1 s core::Timer tick — removing the per-tick issue ceiling (batch x peers / s) that capped the healthy bands. Reward-safety: throughput-only. No height is ever skipped — timed-out heights requeue exactly as before and every CanServe/demote gate has a liveness bypass so no block is ever orphaned; only WHO is asked for fresh work and WHEN a stalled peer is re-eligible changes. The merkleRootMNList per-block self-check + poison fail-closed, merkle bind, and fold content are all UNCHANGED. KATs (test_dash_p2p_node, 143/143 green, real dashbls linked): CanServeBlocks steers ranges only to covering peers (with a no-freeze bypass); stall-demote holds a dead peer off fresh ranges and a delivered body clears it; event refill issues fresh getdata on a body with no tick; and a deterministic in-order dead- peer full span shows the port cuts re-requests 96->32 and raises the delivered rate 6.38->8.00 blk/s while both still deliver every block in order. The [BULK] telemetry gains demoted=<n> and a per-peer !D marker.
…t activation The DML fold's coinbase payee cross-check fired from DIP3 ACTIVATION (h=1028160), but dashd only ENFORCES the coinbase paying GetMNPayee(list) from DIP0003EnforcementHeight (mainnet 1047200). dashd's own consensus rule CMNPaymentsProcessor::IsTransactionValid (masternode/payments.cpp:114) early-returns valid WITHOUT checking the det payee when !DeploymentDIP0003Enforced(nBlockHeight); deploymentstatus.h:52 spells out that DIP3 'active' and 'enforced' are deliberately different statuses. In the [activation, enforcement) window the historical coinbase pays the LEGACY masternode winner, not the deterministic projection, so a cross-check there is STRICTER THAN DASHD and false-poisons a byte-correct fold. Observed in the full-DIP3 self-derive replay: h=1028163 (the 3rd DIP3 block, 14 fresh MNs) poisoned with DML FOLD PAYEE MISMATCH even though merkleRootMNList MATCHED (computed==committed) — the MN SET was right, the projection was sound, the coinbase simply paid the legacy winner. The tie- break itself was already dashd-exact: payee_before() is memcmp over the proTxHash bytes == dashd uint256 operator< (base_blob::Compare), NOT the base_uint arithmetic order that bit Tier-2 (#1271); the SML-root match at the divergence height also proves the proTxHash byte layout is correct. So the fix is the enforcement gate, not the comparison. Mirror dashd exactly: run the payee cross-check only when DeploymentDIP0003Enforced(H) (H >= dip0003_enforcement_height, default 1047200). The merkleRootMNList SET self-check stays UNCONDITIONAL; the payee projection (pass 0) and nLastPaidHeight bookkeeping (pass 5) also run unconditionally, exactly as dashd BuildNewListFromBlock does from activation, so the payee axis stays fully derived and self-consistent across the window and is correct the instant enforcement begins. Production mines at tip (~2.5M), far above enforcement, so the money-path check is UNCHANGED — reward-safe. KAT (test_dash_replay_fold.cpp, DashReplayFoldPreEnforcement): the same state + same payment-block (coinbase pays nobody) POISONS at/above enforcement (strict where dashd enforces) and FOLDS THROUGH below it, with the SET self-check binding in both arms. Verified red (unconditional check poisons at h=102) -> green (gated check folds through). BLS=REAL.
… the header-backfill lane The genesis->anchor header backfill selected its getheaders target by BLIND round-robin over eligible_peers() at maybe_kick_backfill()/on_headers(). That pool's liveness fallback (eligible_bulk_peer_keys) re-admits a pruned / NODE_NETWORK_LIMITED peer whenever the CanServeBlocks-filtered serving set is transiently empty -- exactly the cold-start case: --coin-rpc removed, dashd absent, one limited peer up. That peer silently DROPS the from-genesis getheaders, m_backfill->tip_height() never leaves 0, BulkBlockScheduler stays gated on m_backfill->complete(), bodies=0, the fold never starts (hdr=0/2513000 wedge on vm905). PR #1273 (df10298) ported dashd net_processing's block-download peer policy -- proactive CanServeBlocks + reactive BLOCK_STALLING_TIMEOUT demote -- into the BODY scheduler (pump()). The header-backfill lane was not covered. This extends the SAME two halves to the single-target header walk: * PROACTIVE: reuse the peer_can_serve seam VERBATIM (main_dash -> bulk_peer_can_serve: advertises full-block service AND start_height covers the span AND not bulk-demoted). CanServeBlocks is not reimplemented. The round-robin now only targets a peer that can serve tip_height()+1. * REACTIVE: a header peer that received getheaders but did not advance the walker within the re-kick window is demoted (m_hdr_demoted_until, cooldown parity with the scheduler's demote_cooldown) and the span re-homes to a different serving peer. This catches a peer that ADVERTISES service but lies/drops -- the case CanServeBlocks alone cannot see. on_headers clears the demotion the instant a peer delivers (mirror of on_body clearing demoted_until). Liveness bypass (never skip a span): if no peer advertises the height the filter degrades to a preference; if every serving peer is demoted the demotions are cleared and the walk re-homes -- it never deadlocks. backfill_demote_cooldown_sec = 0 restores byte-identical pre-port blind behaviour (like demote_after=0). Reward-safety: header-fetch peer SELECTION only. PoW + prev-hash header linkage stays strict (HeaderBackfill::add_headers unchanged); the anchor JOIN CHECK, the per-block merkleRootMNList/merkleRootQuorums fold self-check and poison fail-closed are untouched; no header or block is ever skipped -- a demote re-homes the span, it does not drop it. KAT (test_dash_replay_bulk_fetch, DashReplayHeaderBackfillPeerSelect): red (blind round-robin wedges on the non-serving peer, walker stalls short of the anchor) -> green (CanServeBlocks filter joins the anchor without ever targeting the non-server; stall-demote re-homes off a lying full-node peer; liveness bypass never deadlocks with all peers non-answering).
…so the fold resolves a REAL LLMQ_50_60 commitment's PoSe punishes
The self-derive full-DIP3 fold folded 1028161..1081594 byte-clean
(folded=53434, roots_matched=53434) and then failed CLOSED at h=1081595
on the FIRST non-null early LLMQ_50_60 (llmqType=1) qfcommit:
fold FAILED at h=1081595 tx[1]: quorum-member resolver has no member
set for llmqType=1 quorumHash=0000000000000001... — failing closed
Root cause is NOT commitment_is_null(): it is already dashd
CFinalCommitment::IsNull-faithful and the h=1081595 commitment is
genuinely non-null (41/50 signers, real pubkey/vvecHash/sigs) — a real
DKG era (mainnet's first mined LLMQ_50_60, May 2019) whose 9
invalid-marked members dashd's HandleQuorumCommitment PoSe-punishes.
Resolving its member set is required for MN-list parity.
The gap is member PRODUCTION below the V20 floor. QuorumReplayEngine::
observe_block() refuses every block under v20_floor (mainnet 1'987'776)
— the CL modifier era, quarter-rotation and the merkleRootQuorums
self-check are all post-V20 / Phase-2 — so the W4 lane produced NO
member set the whole early-DIP3 run (quorum_lane_refusals counted one
per block) and the W1 fold's punish pass fell to the fail-closed site.
Fix: wire dashd's pre-V20 non-rotated ComputeQuorumMembers as a
below-floor PRODUCER. For a non-rotated DKG base block B (B % dkgInterval
== 0) below the floor, the member set is (dashpay/dash llmq/utils.cpp,
verified against develop):
* member list = GetListForBlock(B) — the list AS OF the base block,
NO -8 work-block offset (the offset lives only in GetHashModifier's
ChainLock lookup; ComputeQuorumMembers feeds the base-block list).
Sourced as the W1 fold's just-proven DML at B (post_fold), which IS
GetListForBlock(B) by construction.
* modifier = ::SerializeHash(make_pair(llmqType, blockHash(B))) —
GetHashModifier's pre-V20 branch (DeploymentActiveAfter(..,V20) is
false), no ChainLock term. Byte-exact compute_quorum_modifier's
CL-absent branch fed the BASE hash.
* selection = CalculateQuorum(size, modifier) over confirmed+valid
MNs (compute_nonrotated_members / eligible() == CalculateScores).
Keyed by (llmqType, blockHash(B)) via the same m_height_by_hash the
forward path uses, so members_for(type, quorumHash) resolves a
commitment whose quorumHash == its DKG base block hash. Iterates the
CHAINPARAMS llmq list (still carries LLMQ_50_60, mined in this era), not
the runtime-enabled set. STRICTLY below the floor — at/above it
observe_block() stays authoritative and is never shadowed. Wired into
the bridge's post_fold hook (both the replay-fold and mn-diff-store
drivers). commitment_is_null() is UNCHANGED: a genuinely null early
commitment still skips before any member set is consulted.
Reward-safe by construction: a WRONG member set re-hashes to the wrong
merkleRootMNList and the fold's per-block committed-cbTx-root self-check
refuses forward — never a bad mint.
KAT (test_dash_replay_quorum_seam, red->green):
* EarlyNonRotatedGateRefusesWithoutTheProducer — the poison gate:
below the floor observe_block refuses and members_for is nullopt.
* EarlyNonRotatedProducerResolvesMembersFaithfully — GREEN: the
producer resolves the LLMQ_50_60 set, byte-identical to dashd's
pre-V20 ComputeQuorumMembers, banned MNs excluded, and the pre-V20
modifier is distinct from the post-V20 (work-block-hash) form.
* EarlyProducerIsANoOpAtOrAboveTheV20Floor — the below-floor scope.
127-test suite green (1 data-driven skip).
… payee cross-check
The DML fold's pass-6 payee cross-check unconditionally required the
projected masternode's scriptPayout among the coinbase outputs. dashd
(masternode/payments.cpp GetBlockTxOuts:64-77) does not always emit it:
when nOperatorReward==10000 and scriptOperatorPayout is set, the operator
reward eats the entire MN share, masternodeReward folds to exactly 0, and
the ONLY MN output dashd emits — and its IsTransactionValid:109-139
requires — is scriptOperatorPayout. Demanding scriptPayout there is
stricter than dashd and false-poisons a byte-correct fold.
Live self-derive incident: h=1439234, proTxHash
71ed3bf59baa91d914ed93b6534cb8e2819f230167b6228cf77b9e03ceb2d006,
bps=10000, owner==operator self-host. The coinbase paid the operator
script for the full 1.51818084 share with no owner remainder; the
merkleRootMNList self-check PASSED (411073/411073), yet the fold hard-
stopped "engine poisoned, re-seed required". This recurs for every
100%-operator-reward payment (~every queue length while registered), so a
point patch would re-wedge; the general dashd-faithful branch is required.
Pass 0b now captures the payee's full pre-block payment tuple
(scriptPayout, scriptOperatorPayout, nOperatorReward — state already held
by fold_proreg/fold_proupserv). Pass 6 mirrors GetBlockTxOuts without
needing amounts:
- bps==10000 && operator-script set -> require scriptOperatorPayout, NOT
scriptPayout (dashd emits none).
- 0<bps<10000 -> masternodeReward = mnShare - floor(mnShare*bps/10000)
is provably >0, so require scriptPayout (today's behaviour); the
operator output's exact amount needs the fee reward the fold cannot
price (W5 gap), so never poison on the operator axis for a partial
split.
- otherwise -> require scriptPayout.
The poison message now dumps {scriptPayout, scriptOperatorPayout, bps} so
any future instance self-classifies.
Reward-safe by construction: a check-only change on the verification axis.
The requirement becomes EXACTLY dashd's required-output set, never weaker
— at bps=10000 a coinbase paying scriptPayout instead still hard-stops
(dashd would reject it too). The merkleRootMNList SET self-check and the
payee projection + nLastPaidHeight bookkeeping (passes 0/5) are untouched;
no live-mint behaviour changes.
KATs (test_dash_replay_fold.cpp, DashReplayFoldOperatorSplit):
- FullOperatorRewardPaysOperatorScriptFoldsThrough — the h=1439234 shape;
RED on the old selection (poisons a byte-correct fold), GREEN with the
faithful branch.
- FullOperatorRewardPayingOwnerScriptStillPoisons — strictness: a bps=10000
coinbase paying the owner script is invalid and still poisons.
- PartialOperatorRewardRequiresOwnerScript / ...MissingOwnerScriptPoisons —
partial splits keep requiring scriptPayout, unchanged.
…old the h=1516043 PoSe double-punish ban QuorumReplayEngine::produce_early_nonrotated_members computed the platform LLMQ (mainnet LLMQ_100_67 type 4) member set with evo_only UNCONDITIONALLY true. dashd only restricts the platform quorum to EvoNodes from v19 on (llmq/utils.cpp GetAllQuorumMembers: EvoOnly = isPlatform && IsV19Active(pQuorumBaseBlockIndex)); before v19 it draws members from ALL confirmed+valid MNs. Since no EvoNodes exist until h~=1899072, the pre-v19 platform member set folded EMPTY and every qfcommit PoSe punish it owed was silently skipped. That silently dropped the type-4 punish at the FIRST-EVER LLMQ_100_67 commitment (DIP0020 activated the quorum at h=1516032). Mainnet block 1516043 carries two commitments — LLMQ_50_60 (type 1, invalid member 33) and LLMQ_100_67 (type 4, invalid member 26) — whose invalid member is the SAME masternode 86f863af. dashd applies CalcPenalty(66)=3072 per commitment; 3072+3072=6144 >= CalcMaxPoSePenalty 4656 -> instant PoSe-ban -> its SML isValid flips 1->0. With the type-4 punish dropped, the engine applied only +3072 (no ban), left isValid 1, and the folded merkleRootMNList stuck at the 1516042 list (ecced0a4...) while the block committed 028ef00b... — the full-history replay's DIVERGED_AT=1516043. Fix, mirroring dashd exactly: - QuorumReplayConfig gains v19_activation (mainnet 1899072, testnet 850100), same shape as v20_floor; wired at both replay-fold-quorum config sites. - produce_early_nonrotated_members gates evo_only on base_height >= v19_activation && isPlatform. - fold_qfcommit hardens fail-closed: an EMPTY member set on a commitment that still carries invalid-marked members is a derivation defect (a mined commitment never has an empty member set in dashd), so refuse rather than fold a knowingly-incomplete punish pass. Reward-safe by construction: the merkleRootMNList self-check, the payee cross-check and the poison latch stay strict — a genuinely wrong fold still HARD-STOPs and withholds, never mints. This only widens type-4 eligibility pre-v19 back to dashd's exact domain, and every block past 1516043 must keep byte-matching its committed cbTx root. KAT (test_dash_replay_v19_type4_punish.cpp), reads the real archival mnlistdiff for h=1516032 (4656-entry SML, CalcMerkleRoot == committed ecced0a4...): - EarlyPlatformQuorumMemberSetIsFullAndHoldsBannedMN: the pre-v19 type-4 set is a full 100-member set (RED: empty on master) with 86f863af at member 26, and 86f863af at type-1 member 33. - FoldReproducesCommittedRootAt1516043: folding block 1516043 through the engine-derived member sets reproduces the committed root 028ef00b... byte-exact (RED on master: sticks at ecced0a4..., engine poisoned — the exact live divergence signature). - PlatformEvoOnlyGateFollowsV19BothDirections: synthetic all-regular list — platform quorum full below v19, evo-only (empty) at/after v19; the non-platform type-1 quorum unaffected.
…e dashd-cut fold stack (onion #1259-#1277 + operator-reward payee split #1284 + v19 platform-quorum punish gate #1287) Union of the two stacks over a common base (#1257 801561c): - fetch-lane files (replay_bulk_fetch.hpp + KAT) take master, whose parallel getheaders window is the evolved superset of the fold branch's earlier single-inflight header-lane port (BLOCK_STALLING_TIMEOUT/CanServeBlocks). - fold-stack files (main_dash.cpp store-engine quorum resolver + DIP-24 straddle bootstrap + cold-bridge/W5 UAF guards, replay_fold_consumer.hpp dump-hook seam) keep the fold additions; master's fetch wiring auto-merged. - No merkleRootMNList self-check, payee cross-check, poison latch, or reward-path guard weakened. Fold write-feed retains poison/size-0/root- checked on_folded guards; the g_replay_fold_prestate UAF fix preserved.
This was referenced Aug 20, 2026
Draft
This was referenced Aug 20, 2026
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.
THE dashd-cut onion landing vehicle. This is a
git mergeof master into the combined fold-tip (conflicts pre-resolved, KATs green), NOT the granular #1263-#1287 drafts — those sit on a stale base (#1257) and carry pre-merge originals of #1259/#1260/#1261, so rebasing them onto master is a deadbase-revert of evolved reward-path fold code. This branch already carries the evolved fold engine.Contents (all reward-path — operator review + merge tap, do NOT auto-merge): the self-derive fold engine (straddle/backfill/empty-DIP3-seed/checkpoint-dump/UAF fixes), DIP3-payee tiebreak, operator-reward split mirror, pre-v19 platform-quorum type-4 double-punish gate, bulk-throughput CanServeBlocks, tx-selection IS/CL-hold + DSTX exclusion-discipline, and distributed won-block broadcast.
+20 / -2 vs master. Supersedes the granular drafts #1262/#1263/#1266-#1270/#1274/#1275/#1277/#1284/#1287 (their content is here, evolved). #1290 (incremental SML root) and #1292 (anti-wedge re-ask) fold on top of this. #1289 (SIMD) lands to master independently first, then rebuild this on master+#1289 so SIMD isn't duplicated.
Draft: opened now so CI validates the full onion payload in parallel with the cold-start anchor derivation (task #154). Merge sequence at cut-time: #1289 → #1229 → THIS → #1290 → #1292.