Skip to content

dash(daemonless): combined fetch+fold onion — THE dashd-cut landing branch - #1293

Draft
frstrtr wants to merge 20 commits into
masterfrom
integrator/dashd-cut-combined-fetch-fold
Draft

dash(daemonless): combined fetch+fold onion — THE dashd-cut landing branch#1293
frstrtr wants to merge 20 commits into
masterfrom
integrator/dashd-cut-combined-fetch-fold

Conversation

@frstrtr

@frstrtr frstrtr commented Aug 20, 2026

Copy link
Copy Markdown
Owner

THE dashd-cut onion landing vehicle. This is a git merge of 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.

frstrtr and others added 20 commits August 17, 2026 02:46
…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.
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