Skip to content

dash(embedded): daemonless superblock payee sourcing via govsync (governance triggers)#810

Merged
frstrtr merged 4 commits into
masterfrom
dash/embedded-superblock-govsync
Jul 26, 2026
Merged

dash(embedded): daemonless superblock payee sourcing via govsync (governance triggers)#810
frstrtr merged 4 commits into
masterfrom
dash/embedded-superblock-govsync

Conversation

@frstrtr

@frstrtr frstrtr commented Jul 23, 2026

Copy link
Copy Markdown
Owner

What / why

Closes the last daemonless gap for --embedded-mainnet: superblock heights. The DASH embedded arm builds templates daemonlessly for every height class (CbTx byte-proven, DKG-window type-6 quorum commitments synthesized per E1 #803, credit-pool per E2, SML/quorum persistence per E3) except superblock heights, where the coinbase must pay the governance-determined treasury (superblock) payees. The E4 soak confirmed superblock 1519464 fell back to dashd reward-safe by design. This PR sources those payees daemonlessly.

Draft — do NOT merge. Awaiting re-review + soak. The daemonless-serve path is opt-in (--embedded-superblock, default OFF) and fails closed until BLS vote-verify + the govsync-completeness proof land (see "Stubbed / follow-up").

Rev 2 — reworked per the adversarial review (R1–R6 + parser hardening):

  • R1: core::address_to_script's P2SH whitelist gained DASH's versions 0x10 (mainnet '7…') / 0x13 (testnet) — before, a mainnet P2SH superblock payee (the treasury-multisig class) built a P2PKH script dashd rejects. Verified non-colliding with every supported coin's P2PKH bytes; cross-coin byte-exactness (LTC/DOGE/BTC/DASH) pinned in a KAT that fails on the old core. The trigger parser additionally decodes chain-strict in the dash module (DecodeDestination parity): wrong-chain / other-coin / bech32 payees fail the trigger closed.
  • R2: the govobj hash is now the exact dashcore Governance::Object::GetHash preimage (excludes collateralHash+type, HexStr(vchData) string layer, legacy dummy bytes) — pinned byte-exact against two from-wire testnet objects. Wire vchData is now correctly treated as plaintext JSON bytes (no hex layer on the wire).
  • R3: vote-verify direction corrected everywhere: trigger funding votes are BLS-signed by the MN's operator key (dashcore IsValid: the ECDSA/keyIDVoting path is proposal-only). The verifier seam stays UNSET (fail closed); the exact BLS scheme, signed digest (govvote_signature_hash, pinned; identity hash pinned against two from-wire votes), time bound, and DMN-lookup requirement are documented at the seam.
  • R4: dashcore tally semantics — per-vote weight seam (EvoNodes 4×, membership-at-tally; unset ⇒ weight 0 ⇒ fail closed) and threshold max(nGovernanceMinQuorum, weighted_count/10) re-derived from the SML's weighted count on every accepted mnlistdiff (zeroed on SML reorg).
  • R5 (structural): resolve_superblock additionally requires a govsync-completeness predicate (set_superblock_sync_complete_fn) present AND true — default-absent ⇒ refuse ⇒ dashd fallback. No production caller sets it, so landing vote-verify alone can never open the serve path from a partial governance view.
  • R6: superblock desync cross-check on the block-connect leg (analogue of the MN payee-desync latch, dash(embedded): projection-based MN payee attribution — fix soak-found intermittent bad-cb-payee [DO NOT MERGE: gate on re-soak] #807): a trigger-confident schedule that does not appear in the accepted superblock coinbase clears the governance store and latches the superblock arm closed (survives re-ingestion; restart-only unlatch). Executed cycles are pruned. Guarded by the superblock-height predicate — no false-fire off superblock heights (KATs both ways).
  • Parser hardening (dashd-reject parity): mandatory type, integer event_block_height only, mandatory proposal_hashes (count match + 64-hex each), dashcore-exact ParsePaymentAmount grammar (no +/-, ≤20 chars, no leading dot/zeros, single dot, ≤8 frac digits, MoneyRange), legacy nested-array form rejected, trigger store bounded (64), per-vote logging demoted to DEBUG.

Dash Core reuse (mostly-reuse mandate)

dashcore c2pool port
CSuperblock::ParsePaymentSchedule + ParsePaymentAmount (governance/classes.cpp) governance_object.hpp::parse_superblock_trigger / parse_payment_amount
Governance::Object::GetHash (governance/common.cpp) governance_object.hpp::govobject_hash
CGovernanceVote::GetSignatureHash / GetHash (governance/vote.h) govvote_signature_hash / govvote_identity_hash
CGovernanceObject::CountMatchingVotes + UpdateSentinelVariables threshold GovernanceStore weighted tally + governance_funding_threshold
CSuperblockManager::GetBestSuperblock (funding-tally winner) governance_store.hpp::get_best_superblock
CSuperblockManager::GetSuperblockPayments / CalcSuperblockBudget superblock.hpp::get_superblock_payments / superblock_budget
CGovernanceObject / CGovernanceVote wire, commands govsync/govobj/govobjvote p2p_messages.hpp

govsync leg (mirrors the E1 mnlistdiff sourcing pattern)

  • p2p_messages.hpp: govsync (request) / govobj / govobjvote.
  • p2p_client.hpp: handlers fire new_govobject/new_govvote with dashcore-exact digests; send_govsync() primed at handshake.
  • govsync_ingest.hpp + coin_state_maintainer.hpp: feed the in-memory GovernanceStore (cycle-ephemeral; a restart re-syncs).
  • node_coin_state.hpp / work_source.hpp / embedded_gbt.hpp: the winning trigger's schedule is threaded into the coinbase (appended payees + augmented coinbasevalue, matching dashd GBT).

Known gap (pre-enable requirement, documented at the call site): dashcore answers govsync with inventory (gov invs), not direct govobj/govobjvote messages, and our inv handler does not getdata governance types — the ingest leg is currently inert (an extra fail-closed layer: the store stays empty). Inv-driven getdata + per-object vote sync + periodic re-prime co-land with vote-verify.

Fail-closed (never guess superblock payees)

With --embedded-superblock ON and all seams unset, zero superblock templates are served — five independent layers deep:

  1. the sync leg is inert (store stays empty);
  2. the vote-weight/membership seam is unset (every vote weighs 0);
  3. the vote verifier is unset (no vote is ever counted);
  4. the funding threshold is 0 until gov params + a non-empty SML seed it;
  5. the R5 completeness gate is absent (structural refuse at resolve_superblock).
    set_require_superblock_provider(false) (the default) preserves prior behaviour byte-for-byte (every superblock height falls back).

KATs — test_dash_superblock.cpp (24 tests, all pass)

Pinned against from-wire testnet vectors (dashd @192.168.86.52):

  • ParsePaymentSchedule → byte-exact scriptPubKey vs validateaddress, incl. P2SH (8xXa…a914…87, dashd-confirmed) and the mainnet '7…' treasury class; chain-strict wrong-chain rejection (dashd-confirmed "Invalid prefix").
  • R1 core regression KAT: core::address_to_script('7…'/'8…')a914…87; fails on the pre-fix core; LTC/DOGE/BTC vectors byte-unchanged.
  • govobject_hash byte-exact vs two gobject list objects; govvote_identity_hash byte-exact vs two gobject getcurrentvotes votes; govvote_signature_hash structurally pinned.
  • superblock_budget(1519824, 24) == getsuperblockbudget == 1428625704 duffs (exact).
  • Fail-closed ladder: weight-seam unset ⇒ 0 tally; sub-threshold ⇒ refuse; threshold boundary walk (16→17); NO-vote demotion; unknown threshold ⇒ refuse; over-budget ⇒ refuse; EvoNode 4× + membership-drop; threshold formula vs live getgovernanceinfo (17); store bound + prune.
  • R5 gate ladder: provider + flag + confident trigger without the completeness predicate ⇒ refuses; predicate false ⇒ refuses; true ⇒ serves the exact schedule.
  • R6 latch suite: mismatch ⇒ clear + latch (survives re-ingestion); match ⇒ no latch + prune; off-superblock heights ⇒ never fires.
  • build_embedded_workdata emits the superblock output + augments coinbasevalue.

Stubbed / follow-up (documented, reward-safe by construction)

  • BLS operator-key vote verification (set_vote_verifier, unset): scheme + digest + time bound + DMN-lookup requirement documented at the seam; requires the bls-signatures lib and a from-wire vote + operator-key vector pin. The outpoint→MN weight mapping (set_vote_weight_fn, unset) needs the full DMN view — the DIP-4 SML lacks collateral outpoints.
  • govsync inventory leg (see "Known gap") + wire-layout byte-pin against a raw capture.
  • govsync-completeness predicate (R5): must be wired deliberately with its own proof obligations before any serve.
  • Funded-superblock soak (the actual gate): devnet/regtest funded trigger incl. a P2SH payee crossed with the arm serving and dashd accepting; mainnet read-only shadow soak (hash/tally/winner/coinbase byte-match + mid-window restart); negative legs (kill-peer ⇒ refuse; unfunded ⇒ fallback; injected wrong winner ⇒ R6 latch).

Testing

  • c2pool-dash full binary builds clean (GCC 13, Debug).
  • test_dash_superblock 24/24; test_dash_embedded_gbt 30/30, test_dash_get_work 4/4, test_dash_subsidy 8/8 unchanged; address suites (test_address_resolution 39, test_redistribute_address 12, test_multiaddress_pplns 31) green against the R1 core change.
  • Pre-existing unrelated failure test_dash_coin_state_maintainer.PayeeDesyncWipesDemotesAndFiresReseed reproduces on clean origin/master (not introduced here).

@frstrtr

frstrtr commented Jul 23, 2026

Copy link
Copy Markdown
Owner Author

Adversarial review — daemonless superblock payee sourcing (reward-safety focus)

Verdict: CHANGES-REQUIRED (draft-appropriate: the fail-closed audit for the current binary state PASSES — see below — but the PR contains latent reward-critical defects in exactly the code the KATs present as proven, plus one false safety-claim comment, and the completeness gate must be encoded as a hard precondition rather than a follow-up note).

All dashcore references below verified against dashpay/dash master (governance/common.{h,cpp}, governance/object.cpp, governance/vote.{h,cpp}, governance/classes.cpp, governance/governance.cpp).


Reward-critical findings (ranked)

R1. P2SH superblock payees produce a WRONG scriptPubKey → dashd rejects the block.
parse_superblock_trigger decodes addresses via ::core::address_to_script. address_to_hash160 (src/core/address_utils.cpp) classifies P2SH by a version-byte whitelist {0x32, 0x05, 0x3a, 0xc4, 0x16} — DASH P2SH versions 0x10 (mainnet '7…') and 0x13 (testnet '8/9…') are NOT in it. A DASH P2SH payee therefore falls through to addr_type="p2pkh" and yields 76a914 <scripthash> 88ac instead of a914 <scripthash> 87. dashd's CSuperblock::IsValid will not find its expected P2SH output → bad-cb-payee-class rejection → lost block. Mainnet superblock payees are routinely P2SH (multisig treasury addresses), so this is not a corner case. The KAT only pins P2PKH (yeRZ…). Must fix in this PR (chain-aware decode in the dash module, not core's chain-agnostic whitelist) and add a P2SH KAT (e.g. a mainnet '7…' address → a914…87 byte-exact).

R2. The govobj object-hash computation does not match dashcore — and the comment claims it does.
ADD_P2P_HANDLER(govobj) computes Hash(pack(full message)) and the comment says "same field order the message writes, incl. vchSig, matching CGovernanceObject::GetHash". dashcore Governance::Object::GetHash() is explicitly not the serialization: preimage is hashParent, revision, time, HexStr(vchData) (as a serialized string), masternodeOutpoint + dummy uint8_t{} + 0xffffffff, vchSig — it excludes collateralHash and type and hex-string-encodes vchData ("Note: doesn't match serialization" in dashcore itself). Consequence: our store keys will never equal the parent_hash carried by votes → has_trigger(v.parent_hash) never matches → zero votes ever attach. Today that is an extra fail-closed layer; after vote-verify lands it is a silent never-serves defect. Fix the hash (port the exact preimage incl. dummy bytes) and fix the comment now — a false "matches dashcore" claim in reward-critical code is itself a hazard.

R3. Trigger funding votes are BLS-operator-signed, not ECDSA/keyIDVoting — the documented follow-up verifier design is the wrong scheme.
dashcore CGovernanceVote::IsValid(..., useVotingKey): useVotingKey (ECDSA vs keyIDVoting) applies only to PROPOSAL+FUNDING votes. For TRIGGER funding votes — the only votes this feature tallies — the check is CheckSignature(dmn->pdmnState->pubKeyOperator.Get()), i.e. BLS against the operator key. Every comment in this PR (maintainer seam, node_interface, main_dash) directs the follow-up implementer to "ECDSA-verify against keyIDVoting", which would verify zero genuine trigger votes. Correct the documented contract now so the follow-up is built to the right scheme (BLS/operator for triggers, plus dashcore's nTime <= now+1h and tally-time MN-membership checks).

R4. Vote tally semantics diverge from dashcore once votes flow.
dashcore CountMatchingVotes (a) weights each vote by voting_weightEvoNodes count 4× — and (b) only counts a vote if the MN outpoint is still in the tip MN list at tally time. GovernanceStore::absolute_yes_count counts every stored vote at weight 1 forever. Threshold: dashcore nAbsVoteReq = max(nGovernanceMinQuorum, weighted_count/10); the PR seeds sml().size()/10 + 1 — unweighted, no min-quorum floor, +1 vs floor — and seeds it exactly once at startup (the "a live node re-seeds as the SML grows" comment has no corresponding code; on a cold start it seeds 0 and the arm can never serve until restart). A lower-than-dashd threshold is the unsafe direction: our max-yes trigger could clear our threshold while dashd considers the height untriggered → we serve a superblock dashd validates as a normal block → rejected. Required before enable: weighted tally + membership-at-tally + dashcore-exact threshold, re-derived on SML updates (and cross-checked against getgovernanceinfo.fundingthreshold where a daemon is present).

R5. THE dangerous case (partial govsync → confidently-wrong winner) is real and the gate must be hard, not a note.
get_best_superblock answers from whatever subset the store holds. With R1–R4 fixed and vote-verify landed, a store missing the higher-yes competing trigger (or its votes) yields a confident wrong winner → dashd rejects (CSuperblock::IsValid checks against ITS best) → lost superblock. Today this is unreachable (see fail-closed audit), but nothing in the code structurally ties "verifier set" to "completeness gate present". Required in this PR: encode the invariant so it cannot be forgotten — e.g. NodeCoinState::resolve_superblock additionally requires a superblock_sync_complete_fn (default absent = refuse), so landing vote-verify alone cannot open the serve path. A follow-up comment is not a gate.

R6. No superblock desync cross-check (the analogue of the MN payee-desync latch).
The MN axis learned this the hard way (#807, block 2508008): projection + coinbase cross-check + demote-and-reseed. The superblock path has nothing: if our winning schedule disagrees with the actual superblock coinbase we ingest, nothing wipes the gov store, demotes, or alarms. Superblocks recur (~monthly mainnet), so a persistent divergence would silently repeat. Required before enable: on ingesting a block at a superblock height, compare our resolved schedule (if we were trigger-confident) against the actual coinbase outputs; mismatch ⇒ clear/demote + loud log, mirroring payee_desync. (Verified the existing MN desync check does NOT false-fire on superblock heights — it scans for presence of the projected MN script among outputs, extra treasury outputs are ignored. Good.)


Fail-closed audit (question: can flag-ON with stubs unset ever serve?) — PASS, quadruply

Traced every mutation path into GovernanceStore in production code:

  1. No governance data can currently arrive at all: dashcore answers govsync with inventory (CInv MSG_GOVERNANCE_OBJECT/MSG_GOVERNANCE_OBJECT_VOTE via GetSyncableObjectInvs), not direct govobj/govobjvote messages — and our inv handler only acts on block invs, never getdata's governance types. Additionally, an nProp=0 sync returns object invs only; votes require per-object govsync requests. The ingest handlers are therefore unreachable from the current wire flow (also means the feature is functionally inert as shipped — see F1).
  2. Even if objects+votes arrived: R2's hash mismatch means votes never attach to triggers.
  3. Even if they attached: on_govvote counts nothing unless m_vote_verifier is set; set_vote_verifier has zero production callers (verified by grep); add_verified_funding_vote has no other production call site.
  4. Even with votes: get_best_superblock requires m_funding_threshold > 0 and yes >= threshold; threshold is 0 on any cold start.
    get_superblock_payments additionally nullopts on empty/zero-amount/over-budget schedules. There is no path where a trigger wins without verified votes. Unfunded-superblock behaviour (3c): the provider can never actually return the documented empty-vector "serve normal block" — get_superblock_payments nullopts when no winner — so unfunded superblock heights route to dashd, which serves the correct normal template. Safe (but the node_coin_state.hpp/embedded_gbt.hpp empty-vector docs describe a dead path; either implement the distinction behind the completeness gate or fix the docs).

Trigger-selection shape (question 1): dashcore applies the threshold via IsSuperblockTriggered (any trigger ≥ nAbsVoteReq) then GetBestSuperblockInternal picks max-yes with strict > (first-in-ascending-hash-map wins ties ⇒ lowest hash). The PR's per-trigger-threshold-then-max with lowest-hash tiebreak is outcome-equivalent. The divergence risk is entirely in the threshold value and vote weights (R4), not the selection shape. dashcore also gates on IsValidBlockHeight inside GetBestSuperblock; ours relies on m_is_superblock_fn — equivalent given the provider is only consulted at superblock heights.


Parse divergences (accept-more-than-dashd; vote-gated in practice but weakens defense-in-depth)

dashcore ParsePaymentSchedule + ParsePaymentAmount also require, and we don't:

  • proposal_hashes split count == payee count, each a valid hash (ignored entirely — note the KAT's own vector "proposal_hashes":"abc" is a trigger dashd would REJECT, so the "exact vector dashd would place" claim is narrower than advertised);
  • amount string: no +/- characters at all, no leading ., no leading zeros, ≤20 chars, MoneyRange cap (we accept +5.0, .5, 007, up to ~92e9 DASH);
  • JSON type field mandatory for triggers (we accept missing type);
  • legacy array form: dashcore takes objResult[0][1] (first element); we take j.back() — divergent on multi-element arrays; simplest is to reject arrays outright (dead legacy format);
  • address must be valid for the active chain (DecodeDestination); address_to_script accepts any chain's base58 and even bech32 (which would build a witness script Dash has no concept of).
    Each of these admits triggers into our store that dashd rejects at ingest. MN votes make them unelectable in practice, but the parser's contract says "fails closed on ANY malformation dashcore would reject" — make it true.

Functional / robustness

  • F1: govsync leg is inert (see audit item 1): needs getdata on gov invs + per-object vote sync. Also "Re-primed on tip changes below" — send_govsync has exactly one call site (handshake); no re-prime exists.
  • F2: on_govobject accepts any well-formed trigger from the peer with no MN-signature/rate-limit validation into an unbounded map; prune_executed is never called. Memory growth + spoofed-trigger surface (vote-gated for election, but store pollution is free). Bound the store and wire pruning to block-connect.
  • F3: per-govobjvote INFO logging — mainnet governance sync is tens of thousands of votes; demote to DEBUG.
  • F4: budget KAT pins testnet only; mainnet superblock_budget is asserted > 0. Pin a mainnet getsuperblockbudget value. Also note the formula assumes the post-V20 20% part for all heights — fine for current heights, worth a comment.
  • F5: attribution-gate CI is red on the PR body — strip the flagged wording and re-push the body.

Pre-existing-failure verification

Confirmed empirically: DashCoinStateMaintainer.PayeeDesyncWipesDemotesAndFiresReseed fails identically at the merge-base (70969f0, clean master, fresh build in a throwaway worktree) and on this branch. #810 does not touch that test or the desync logic (its maintainer/node-state changes are additive; resolve_superblock with the gate OFF is semantically identical to the old m_is_superblock_fn check). Genuinely the #807 residual, unrelated to #810. The 11 new superblock KATs pass on the branch.

Required soak scope (the actual gate — this area is 6× soak-refuted)

The soak MUST cross a funded superblock with the embedded arm's schedule checked against a dashd-accepted block. Since testnet superblocks are currently unfunded:

  1. Devnet/regtest funded-superblock vector: controlled MN set, submit a trigger (include at least one P2SH payee — R1), cast operator-BLS funding votes past threshold, cross the superblock height with --embedded-superblock serving, and have dashd accept the produced block. This is the only end-to-end proof of the serve path.
  2. Mainnet read-only shadow soak (no serving): govsync-ingest against mainnet peers across a real superblock window (triggers appear days before heights % 16616): assert (a) our object hashes == gobject list hashes, (b) per-trigger absolute-yes == dashd's, (c) our winner == dashd's, (d) our (script,amount) vector byte-matches the actual mainnet superblock coinbase, (e) a mid-window restart rebuilds the store and reconverges.
  3. Negative legs: peer killed mid-sync ⇒ refuses (nullopt) at the height; unfunded height ⇒ dashd-fallback serve with 0 bad-cb-payee; desync cross-check (R6) fires on an injected wrong-winner.

Bottom line: default-OFF is genuinely reward-safe today and even flag-ON cannot serve (quadruple fail-closed, verified). But R1 (P2SH mis-script) sits inside the "proven by KAT" core, R2's comment asserts a false safety property, R3 documents the wrong verification scheme for the exact votes being tallied, and R5's gate must be structural. Fix R1/R2-comment/R5-structure in this PR; R3/R4/R6 + F1 are hard preconditions for ever letting the flag serve.

frstrtr and others added 3 commits July 26, 2026 02:11
…ernance triggers)

Close the last daemonless gap for --embedded-mainnet: SUPERBLOCK heights.
Previously the embedded arm unconditionally fell back to dashd at every
superblock height because it could not source the governance-determined
treasury (superblock) payees. This adds the governance-object sync leg over
the existing coin-P2P client and reuses dashcore's superblock consensus logic
to produce the exact (payee, amount) vector daemonlessly, with the same
fail-closed discipline as E1/E2/E3.

Reuse of dashcore (governance/governance-classes.cpp):
  - CSuperblock::ParsePaymentSchedule  -> governance_object.hpp
    parse_superblock_trigger (payment_addresses|payment_amounts split,
    address->scriptPubKey, fixed-point amount)
  - CSuperblockManager::GetBestSuperblock -> governance_store.hpp
    get_best_superblock (funding-tally winner selection)
  - CSuperblockManager::GetSuperblockPayments / CalcSuperblockBudget ->
    superblock.hpp get_superblock_payments / superblock_budget

govsync leg (mirrors the E1 mnlistdiff sourcing pattern):
  - p2p_messages.hpp: govsync (request) / govobj / govobjvote (dashcore
    protocol.cpp command strings + object.h/vote.h field layouts)
  - p2p_client.hpp: handlers fire new_govobject/new_govvote + send_govsync
  - govsync_ingest.hpp + coin_state_maintainer.hpp: GovernanceStore feed
  - node_coin_state.hpp / work_source.hpp / embedded_gbt.hpp: superblock
    schedule threaded into the coinbase (payees + augmented coinbasevalue)

Fail-closed (never guess superblock payees): a superblock height serves the
embedded arm ONLY when a trigger is funding-threshold-confident AND its
schedule is budget-valid; otherwise it falls back to the reward-safe dashd
path. Opt-in via --embedded-superblock (default OFF preserves prior behaviour
exactly). Vote-ECDSA-verify against keyIDVoting is the documented follow-up:
the verifier seam is UNSET by default so no vote is counted => no trigger wins
=> superblock heights fail closed even with the flag on, until pinned + soaked.

KATs (test_dash_superblock.cpp, 11 tests) pin the reused logic against
FROM-WIRE testnet vectors (dashd @192.168.86.52): ParsePaymentSchedule byte-
exact scriptPubKey match, superblock_budget == getsuperblockbudget
(1428625704 duffs @ h1519824 cycle 24), fail-closed below funding threshold,
over-budget rejection, and build_embedded_workdata superblock-output wiring.
R1 (shared core, reward-critical): core::address_to_script P2SH whitelist
lacked DASH's versions — a mainnet '7…' superblock payee (the treasury-
multisig class) decoded to a P2PKH 76a914…88ac script instead of P2SH
a914…87, a coinbase dashd rejects at every superblock. Added 0x10
(mainnet) + 0x13 (testnet) with a per-coin version-map comment; verified
non-colliding with every supported coin's P2PKH bytes and pinned
cross-coin byte-exactness (LTC/DOGE/BTC/DASH) in a KAT that fails on the
old core. The trigger parser additionally decodes CHAIN-STRICT in the
dash module (dashcore DecodeDestination parity): only the active chain's
DASH version pair is accepted — wrong-chain / other-coin / bech32 payees
fail the trigger closed (dashd-confirmed: '7…' is invalid on testnet).

R2: the govobj hash now implements the EXACT dashcore
Governance::Object::GetHash preimage (excludes collateralHash+type,
HexStr(vchData) string layer, legacy dummy bytes) — pinned byte-exact
against two from-wire testnet governance objects. Also fixed the wire
vchData handling: dashcore's vchData is the PLAINTEXT JSON bytes (no hex
layer on the wire; RPC DataHex is hex OF those bytes) — the ingest no
longer hex-decodes it. The false 'matches GetHash' comment is gone.

R3: corrected the vote-verify DIRECTION everywhere: TRIGGER funding
votes are BLS-signed by the MN's OPERATOR key (dashcore IsValid:
useVotingKey only for PROPOSAL funding votes) — the previously
documented ECDSA/keyIDVoting contract would verify zero genuine trigger
votes. The verifier seam stays UNSET (fail closed); the exact BLS scheme
(SetBytes(vchSig,false) / VerifyInsecure over GetSignatureHash), the
signed digest (govvote_signature_hash, pinned; identity hash pinned
against two from-wire votes), the +1h time bound, and the DMN-lookup
requirement are documented at the seam for the follow-up.

R4: tally now matches dashcore CountMatchingVotes semantics: per-vote
weight seam (EvoNodes 4x, membership-at-tally — 0 for an MN no longer in
the valid set; UNSET default = weight 0 = fail closed), and the funding
threshold is max(nGovernanceMinQuorum, weighted_count/10) re-derived
from the SML's weighted count on EVERY accepted mnlistdiff (and zeroed
on SML reorg wipes) instead of a one-shot sml/10+1 seed. The DIP-4 SML
lacks collateral outpoints, so the outpoint→weight mapping is an
explicit seam pending the full DMN view — documented, fail-closed-unset.

R5 (structural): resolve_superblock now additionally requires a
govsync-completeness predicate (set_superblock_sync_complete_fn) to be
PRESENT and TRUE before the serve path can open — default-absent =>
refuse => dashd fallback. No production caller sets it; landing
vote-verify alone can never serve from a partial governance view
(the confidently-wrong-winner hazard). KAT walks the full ladder.

R6: superblock desync cross-check on the block-connect leg — at a
superblock height, if we were trigger-confident, every (script, amount)
we would have served must appear in the accepted block's coinbase;
mismatch clears the governance store and LATCHES the superblock arm
closed (survives re-ingestion; only a restart unlatches). Executed
cycles are pruned (prune_executed now has a caller). Guarded by the
superblock-height predicate — cannot false-fire off superblock heights
(KATs for fire / no-fire / match+prune).

Parser hardening (dashd-reject parity): mandatory type field, integer
event_block_height only, mandatory proposal_hashes with count match +
64-hex validation, dashcore-exact ParsePaymentAmount grammar (charset
[0-9.], <=20 chars, no leading dot/zeros, single dot, >=1 and <=8 frac
digits, MoneyRange), legacy nested-array form rejected outright, trigger
store bounded (MAX_TRIGGERS=64). Per-vote logging demoted to DEBUG. The
handshake govsync comment now states the leg is inert (inventory-based
sync gap) instead of claiming a re-prime that does not exist.

test_dash_superblock: 24 KATs (was 11), incl. P2SH byte-exact vectors
(dashd validateaddress-confirmed), the R1 core regression KAT
(fails-before/passes-after), govobject/govvote hash pins against
from-wire dashd data, weighted-tally/threshold KATs, the R5 gate ladder,
and the R6 latch suite. c2pool-dash + embedded_gbt(30)/get_work(4)/
subsidy(8)/address suites all green; the only failure remains the
pre-existing PayeeDesyncWipesDemotesAndFiresReseed #807 residual
(reproduces at merge-base).

Fail-closed invariant preserved: with --embedded-superblock ON and all
seams unset, zero superblock templates are served — now five
independent layers deep (inert sync leg, weight seam unset, verifier
unset, threshold 0 until gov params + SML, R5 gate absent).
…ck_connected

The #802 body<->header trust-boundary guard (block_body_binds_to_header)
in CoinStateMaintainer::on_block_connected REFUSES any block whose tx set
does not fold to the header merkle commitment. The PayeeDesyncWipes...
KAT built its coinbase-only block but never bound it, so the guard
returned early and the desync/wipe/demote/reseed path never ran ->
EXPECT_TRUE(r.payee_desync) failed. Mirrors the correct usage at the
adjacent normal-block KAT (line 279). Test-only, +1 line.
@frstrtr
frstrtr force-pushed the dash/embedded-superblock-govsync branch from a355369 to ccbd6a1 Compare July 26, 2026 02:16
@frstrtr
frstrtr marked this pull request as ready for review July 26, 2026 02:17
The E-SUPERBLOCK KATs (test_dash_superblock, 24 tests #1529-1552) were
registered via gtest_add_tests AUTO but the target was absent from the
build.yml --target allowlists for both the Linux x86_64 and ASan+UBSan
jobs, so the executable was never compiled -> NOT_BUILT sentinel ->
Unable to find executable -> all 24 Not Run -> ctest exit 8.

The test lives in test/CMakeLists.txt, outside the src/impl/dash/test
scan path of check_test_target_allowlist.py, so the drift-guard did not
flag it.
@frstrtr
frstrtr merged commit e56fe92 into master Jul 26, 2026
28 checks passed
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