diff --git a/CHANGELOG.md b/CHANGELOG.md index d97c56e6f..d139a5394 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## [Unreleased] + +### DASH — daemonless masternode-set seed (E2d) +- **Release-pinned masternode-set checkpoint + forward replay** — `--embedded-mainnet` + can now cold-start the payout-bearing masternode set without a dashd RPC. This was + the last structurally daemon-dependent input on the DASH daemonless path. +- ⚠ **This introduces a documented TRUST ANCHOR.** The pinned masternode set is data + the release build asserts; it cannot be header-authenticated, because the P2P + Simplified MN List omits `scriptPayout`/`nLastPaidHeight` and neither is committed + in `merkleRootMNList`. The node independently verifies the anchor's **chain + position** against its own PoW-validated header chain, its **SHA-256 integrity + digest**, and **every replayed block's coinbase** against the projected payee — but + the set contents at the anchor height are trusted. Fully trustless DIP-3-height + replay remains planned as a later opt-in verify-mode. See the README section + "DASH daemonless masternode-set checkpoint — trust anchor" and + `src/impl/dash/coin/checkpoints/README.md`. +- **Fails closed, loudly.** A missing, unpinned, corrupt, wrong-network, + wrong-chain-position, stale or contradicted anchor refuses to serve embedded + templates rather than guessing a payee. New `--embedded-mn-bridge-max N` bounds how + far back an anchor may be (default 20000 blocks, ≈34 days). +- **Anchors are pinned at release time**, not by hand: + `tools/dash/gen_mn_checkpoint.py pin --network mainnet --rpc-url ... ` rewrites the + checkpoint and prints the provenance block for the release notes; + `... verify PATH` re-validates one already in the tree. +- **Anti-mint latch** — on the embedded arm, masternode-readiness now requires an + authoritative height-stamped snapshot. Previously a cold daemonless start could arm + itself off a single ProRegTx observed in a live block and serve a template whose + entire payment queue was that one accidental registration. + + ## [0.14.0-v36] - 2026-07-10 ### Licensing diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 427de7a35..98328b998 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -5,6 +5,44 @@ documented in the [postmortem archive](https://github.com/frstrtr/the/tree/maste --- +## DASH daemonless masternode set is seeded from a TRUST ANCHOR + +**Severity**: Medium (security-model disclosure, not a defect) +**Affects**: DASH `--embedded-mainnet` run WITHOUT a dashd RPC + +c2pool cannot derive the payout-bearing DASH masternode set from the P2P +network: the Simplified MN List omits `scriptPayout` and `nLastPaidHeight`, +and neither is committed in `merkleRootMNList`. Daemonless DASH therefore +cold-starts from a **release-pinned masternode set compiled into the binary** +and replays blocks forward from it. + +**You are trusting the release build** for the set contents at the anchor +height. The node does independently verify the anchor's chain position against +its own PoW-validated header chain, its SHA-256 integrity digest, and every +replayed block's coinbase against the projected payee — a wrong anchor is +falsified within a few blocks and fails closed — but nothing available to the +node can prove the set itself. + +A fully trustless DIP-3-height replay (~1.5M block bodies) is planned as a +later **opt-in verify-mode**; it is not implemented. + +Two further limitations today: + +- **The shipped mainnet and testnet anchors are UNPINNED.** Pinning requires + RPC access to a synced dashd and is a release-time step + (`tools/dash/gen_mn_checkpoint.py pin`). Until an anchor is pinned, + daemonless DASH **fails closed** — it logs at `ERROR` and refuses to serve + embedded templates rather than guessing a payee. +- **A stale anchor is refused, not worked around.** An anchor more than + `--embedded-mn-bridge-max` blocks behind the tip (default 20000, ≈34 days) + is rejected. A release cut long ago will not daemonlessly mine DASH without + either a fresher build or `--coin-rpc-*`. + +Background: README section "DASH daemonless masternode-set checkpoint — trust +anchor" and `src/impl/dash/coin/checkpoints/README.md`. + +--- + ## Remaining IO-thread tracker callbacks without shared_lock **Severity**: Medium diff --git a/README.md b/README.md index 72cc08a20..ea5f3e80e 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,56 @@ c2pool has four operating modes. The default is a full P2P pool — no flags req Legacy `--standalone` mode (minimal stratum + RPC daemon, no embedded SPV) is available for backwards compatibility. +### DASH daemonless masternode-set checkpoint — trust anchor + +**If you run DASH with `--embedded-mainnet` and no dashd, you are trusting the +c2pool release build for one specific piece of data. This section says exactly +which, and why.** + +To build a DASH block, c2pool must know which masternode is next in the DIP-3 +payment queue. Paying the wrong one produces a coinbase the network rejects +(`bad-cb-payee`) — a mined block thrown away. Ranking the queue needs each +masternode's `scriptPayout` and `nLastPaidHeight`, and **neither is available +from the DASH P2P network**: the Simplified MN List (`mnlistdiff`) omits both, +and neither is committed in `merkleRootMNList`, so there is no header +commitment to check them against. + +So c2pool ships a **release-pinned masternode set** — a trust anchor of the +same class as Bitcoin Core's `assumeutxo` — and replays blocks forward from it +to the current tip. + +**What the node verifies for itself, with no trust:** + +- **chain position** — the anchor names a block hash, and is rejected unless + c2pool's own X11-PoW + DGW-validated header chain holds exactly that hash at + exactly that height; +- **integrity** — a SHA-256 digest over the anchor's contents (an integrity + check on the file, *not* a signature: whoever can change the source can + recompute the digest — it catches accidents, not malice); +- **forward consistency** — every block replayed from the anchor re-derives + the projected payee and compares it against that block's real coinbase, so a + wrong anchor is falsified within a few blocks. + +**What you are trusting:** the membership and payout state of the masternode +set *at the anchor height*. Nothing available to the node can prove it. + +A fully trustless alternative exists — replaying every block from DIP-3 +activation (~1.5M blocks) — and is **planned as a later opt-in verify-mode**. +It is not implemented today. + +**Fail-closed by design.** If the anchor is missing, corrupt, for the wrong +network, in the wrong chain position, further behind the tip than +`--embedded-mn-bridge-max` (default 20000 blocks, ≈34 days), or contradicted +by a replayed block, c2pool logs the refusal at `ERROR` and **refuses to serve +embedded DASH templates**. It falls back to a configured dashd, or serves +nothing. It never guesses a masternode payee. + +Running DASH with a dashd RPC configured does **not** use the anchor at all — +`protx list valid true` is authoritative and is used instead. + +Details, provenance of the shipped anchor, and the release-time re-pinning +procedure: [`src/impl/dash/coin/checkpoints/README.md`](src/impl/dash/coin/checkpoints/README.md). + ### Startup examples ```bash diff --git a/src/c2pool/main_dash.cpp b/src/c2pool/main_dash.cpp index 2b43b265c..339fcdeef 100644 --- a/src/c2pool/main_dash.cpp +++ b/src/c2pool/main_dash.cpp @@ -81,6 +81,8 @@ #include // wire_govobject_ingest / wire_govvote_ingest (E-SUPERBLOCK) #include // get_superblock_payments / superblock_budget (E-SUPERBLOCK) #include // E2c: RPC protx-list MN-set seed (parse_protx_list_seed) +#include // E2d: pinned MN-set checkpoint format + fail-closed parser +#include // E2d: checkpoint -> forward-replay bridge -> leg-4 publish #include // dash::Node — sharechain pool-node (NodeBridge) #include // dash::Config (PoolConfig/CoinConfig) #include // dash::SharechainConfig — P2P_PORT / PREFIX / min-proto SSOT @@ -181,6 +183,38 @@ bool parse_listen(const std::string& str, std::string& host, uint16_t& port) return true; } +// ── E2d (#738): the release-pinned masternode-set trust anchor ──────────── +// Compiled INTO the binary (not loaded from disk) so a daemonless cold start +// cannot depend on a file the operator could lose, swap, or forget to install. +// +// ⚠ THESE ARE TRUST ANCHORS. A node that cold-starts the payout-bearing +// masternode set from one of them is trusting this release build for the set +// contents at the anchor height — there is no consensus commitment to check +// them against (the P2P Simplified MN List omits scriptPayout and +// nLastPaidHeight). What IS verified locally: the anchor's chain position +// against our own PoW-validated header chain, its SHA-256 integrity digest, +// and every replayed block's coinbase against the projected payee. See +// src/impl/dash/coin/checkpoints/README.md and the README section +// "DASH daemonless masternode-set checkpoint". +// +// Re-pinned at release time by tools/dash/gen_mn_checkpoint.py — never by +// hand: the digest line commits the contents, so a hand edit fails closed. +const char* const kDashMnCheckpointMainnet = +#include + ; +const char* const kDashMnCheckpointTestnet = +#include + ; + +// --embedded-mn-bridge-max: how many blocks the checkpoint bridge will replay +// before declaring the anchor STALE and refusing to arm. Held at file scope +// rather than threaded through run_node's already-long parameter list purely +// to keep this slice's diff off that signature; it is written once during +// argument parsing in main(), before any thread exists, and only read +// afterwards. +uint32_t g_mn_bridge_max_blocks = + dash::coin::MnCheckpointLane::kDefaultMaxBridgeBlocks; + // Report the requested sharechain peering topology at run-loop bring-up. Honest // about the deferred live bind: a won/seen share does NOT yet cross the wire // until the sharechain pool-node leaf lands. @@ -220,7 +254,7 @@ void print_banner(const char* argv0) << " [--stratum [HOST:]PORT] [--coin-p2p-connect HOST:PORT]... [--coin-p2p-discover]\n" << " [--web-port PORT] [--web-host ADDR] [--dashboard-dir PATH]\n" << " [--external-ip ADDR]\n" - << " [--embedded-utxo] [--embedded-mainnet]\n" + << " [--embedded-utxo] [--embedded-mainnet] [--embedded-mn-bridge-max N]\n" << " [--embedded-oracle-shadow]\n" << " [--oracle-graduation-blocks N] [--oracle-class-coverage K]\n" << " [--give-author PCT] [-f|--fee PCT] [--node-owner-address ADDR]\n" @@ -2348,6 +2382,13 @@ int run_node(bool testnet, const std::string& rpc_endpoint, // node_coin_state / coin_state (declared earlier) they reference. std::unique_ptr header_chain; std::unique_ptr maintainer; + // E2d: the daemonless MN-set bridge. Constructed for the whole embedded + // arm so the tip-changed driver below can pump it unconditionally, but + // only ARMED on the no-RPC path (an available `protx list` is strictly + // better than a pinned anchor, so the hotel/RPC posture is unchanged). + // Declared AFTER maintainer and BEFORE coin_feed_subs so teardown order is + // subscriptions -> lane -> maintainer -> header_chain. + std::unique_ptr mn_ckpt_lane; std::vector> coin_feed_subs; if (coin_p2p) { const auto dash_params = testnet @@ -2359,6 +2400,19 @@ int run_node(bool testnet, const std::string& rpc_endpoint, header_chain->init(); maintainer = std::make_unique(node_coin_state); + mn_ckpt_lane = std::make_unique(); + mn_ckpt_lane->set_max_bridge_blocks(g_mn_bridge_max_blocks); + + // ANTI-MINT LATCH (E2d prerequisite). Without this, a cold daemonless + // arm can arm MN-readiness off leg 3 ALONE: with no seed the payee set + // starts empty, the first live block carrying a ProRegTx registers ONE + // masternode into it, mnstates().size() != 0 flips m_have_mn, and + // populated() serves a template whose "payment queue" is that single + // accidental masternode. That is a guessed payee — a coinbase the + // network rejects. Require an authoritative height-stamped snapshot + // (the E2c RPC seed or the E2d bridge publish) before MN-readiness can + // ever be true on the embedded arm. + maintainer->set_require_seeded_mn_set(true); // Coin address versions for the embedded coinbase-payee encoding (the // TipAdvance carries them so build_embedded_workdata can encode the MN @@ -3072,9 +3126,19 @@ int run_node(bool testnet, const std::string& rpc_endpoint, header_chain->set_on_tip_changed( [&coin_state, &stratum_server, hc = header_chain.get(), addr_ver, p2sh_ver, ws = work_source.get(), - cp = coin_p2p.get(), sml_base, m = maintainer.get()] + cp = coin_p2p.get(), sml_base, m = maintainer.get(), + mnl = mn_ckpt_lane.get()] (const uint256&, uint32_t, const uint256& new_tip, uint32_t new_height, bool was_reorg) { + // E2d bridge driver. Safe + REACHABLE here: HeaderChain fires + // this callback with m_mutex RELEASED (add_header/add_headers + // copy the pending tip-change out inside the lock scope, close + // it, then invoke), so the lane's self-locking header-chain + // reads below cannot self-deadlock. The pre-existing + // tip_advance_from_chain(*hc, ...) call two lines down already + // relies on exactly that property and is proven live. + // No-op unless the lane was armed on the daemonless path. + if (mnl) mnl->pump(); auto ta = dash::coin::tip_advance_from_chain( *hc, addr_ver, p2sh_ver); if (ta) { @@ -3272,17 +3336,129 @@ int run_node(bool testnet, const std::string& rpc_endpoint, seed_mn_set_from_rpc("payee-desync re-seed"); }); } else { - // Pure daemonless: no dashd RPC to seed from. The DMN set must - // come from a DIP3-height special-tx replay (sync block bodies - // from DIP3 activation, replay ProRegTx/ProUpRegTx/... through - // MnStateMachine::apply_block) — the flagged E2d follow-up slice. - // Flag loudly, don't fail: the run-loop stands up, the tip half - // still syncs, and get_work stays on the (unarmed) fallback. - std::cout << "[run] E2c MN-set seed UNAVAILABLE (embedded arm" - " enabled but NO coin-RPC configured): populated()" - " will wait for the DIP3 special-tx replay path" - " (E2d follow-up) -- templates keep routing to the" - " fallback arm\n"; + // ── E2d (#738): PURE DAEMONLESS MN-SET SEED ──────────────────── + // No dashd RPC, so no `protx list`. The set comes from a + // RELEASE-PINNED CHECKPOINT compiled into this binary, replayed + // forward to the tip through the SAME block-connect ingest leg 3 + // already uses. This is the last structurally daemon-dependent + // input on the daemonless path. + // + // ⚠ THE CHECKPOINT IS A TRUST ANCHOR — the operator is trusting + // this release build for the masternode set at the anchor height. + // Nothing on the DASH P2P network can prove it (the Simplified MN + // List omits scriptPayout/nLastPaidHeight and neither is committed + // in merkleRootMNList). Locally verified: the anchor's chain + // position against our PoW-validated header chain, its SHA-256 + // integrity digest, and every replayed block's real coinbase + // against the payee our anchored set projects. Full trustless + // DIP3-height replay stays a later opt-in verify-mode. + // See src/impl/dash/coin/checkpoints/README.md. + const char* payload = testnet ? kDashMnCheckpointTestnet + : kDashMnCheckpointMainnet; + const std::string net_name = testnet ? "testnet" : "mainnet"; + auto ckpt = dash::coin::parse_mn_checkpoint(payload, net_name); + + // Fail CLOSED and LOUDLY. A wrong payee is a coinbase the network + // rejects — direct revenue loss — so an unusable anchor must be + // impossible to mistake for a working one. arm() latches the lane + // terminally on a !ok checkpoint; populated() never flips, and + // get_work keeps routing to the fallback arm. + mn_ckpt_lane->arm(ckpt); + if (!ckpt.ok) { + std::cout << "\n" + << " ============================================\n" + << " E2d MN-set CHECKPOINT REFUSED -- FAIL CLOSED\n" + << " ============================================\n" + << " " << ckpt.error << "\n" + << " The embedded DASH arm will NOT serve templates.\n" + << " No masternode payee will be guessed.\n" + << " Configure a dashd RPC (--coin-rpc-*) or run a\n" + << " release that carries a " << net_name + << " masternode-set anchor.\n" + << " ============================================\n\n"; + } else { + std::cout << "[run] E2d MN-set checkpoint LOADED: " + << ckpt.entries.size() << " masternodes as-of h=" + << ckpt.height << " (" + << ckpt.blockhash.GetHex().substr(0, 16) << ")\n" + << " TRUST ANCHOR -- these masternode records are" + " asserted by this release build, not derived from" + " the chain.\n" + << " provenance: " << ckpt.source << "\n" + << " generated: " << ckpt.generated << "\n" + << " digest: " << ckpt.digest << "\n" + << " The anchor's chain position, its integrity" + " digest and every replayed block's coinbase ARE" + " verified locally;\n" + << " the set contents at h=" << ckpt.height + << " are NOT (no consensus commitment exists). See" + " src/impl/dash/coin/checkpoints/README.md.\n" + << " bridging forward to the tip (max " + << g_mn_bridge_max_blocks + << " blocks) before the embedded arm may serve.\n"; + } + + // Bridge seams. Each is REQUIRED: a missing one makes the lane + // fail closed rather than stall silently (see the null checks in + // MnCheckpointLane::request_window / publish). + // + // Block fetch by height — the same header-chain hash lookup + + // request_block path the E2b UTXO lane's window refill uses. + mn_ckpt_lane->set_request_block_fn( + [cp = coin_p2p.get(), hc = header_chain.get()](uint32_t h) { + auto e = hc->get_header_by_height(h); + if (e) cp->request_block(e->hash); + }); + mn_ckpt_lane->set_tip_height_fn( + [hc = header_chain.get()]() { return hc->height(); }); + mn_ckpt_lane->set_header_hash_at_fn( + [hc = header_chain.get()](uint32_t h) -> std::optional { + auto e = hc->get_header_by_height(h); + if (!e) return std::nullopt; + return e->hash; + }); + // Publish through the EXACT leg-4 event the E2c RPC seed uses, so + // CoinStateMaintainer::on_mn_list_update takes the bridged set as + // an ordinary authoritative resync — snapshot fence set to the + // bridged height, apply cursor armed for the next live block. + mn_ckpt_lane->set_publish_fn( + [&coin_state](std::vector> set, + uint32_t as_of) { + dash::interfaces::MnListUpdate up; + up.mnstates = std::move(set); + up.as_of_height = as_of; + coin_state.mn_list_update.happened(up); + std::cout << "[run] E2d MN-set BRIDGE COMPLETE: " + << up.mnstates.size() << " masternodes as-of h=" + << as_of << " -> maintainer DMN half ARMED;" + " populated() flips once the header tip" + " syncs\n"; + }); + + // Bridge driver leg: every connected block advances the private + // replay machine. Subscribed to the SAME Node::block_connected + // event as leg 3 (and as the E2b UTXO lane) — the lane only folds + // the exact next height it is waiting for, so live tip blocks + // arriving mid-bridge are ignored rather than folded onto a stale + // cursor. + // + // REACHABILITY (the #878/#881 caller-side-lock class): this fires + // from the live-feed bridge's full_block handler on the io thread + // with NO tracker/maintainer lock held; the lane itself takes no + // lock. The pump() call added to the tip-changed callback below is + // likewise dispatched by HeaderChain with m_mutex RELEASED, so the + // lane's self-locking header-chain reads are reachable, not dead. + coin_feed_subs.push_back( + coin_state.block_connected.subscribe( + [mnl = mn_ckpt_lane.get()] + (const dash::interfaces::BlockConnected& bc) { + mnl->on_block_connected(bc.block, bc.height); + })); + + // Kick the lane once now in case the header chain is already past + // the anchor from a previous run's persisted header DB. + mn_ckpt_lane->pump(); } } @@ -3928,6 +4104,14 @@ int main(int argc, char** argv) oracle_grad_blocks = std::strtoull(argv[++i], nullptr, 10); else if (std::strcmp(argv[i], "--oracle-class-coverage") == 0 && i + 1 < argc) oracle_class_coverage = std::strtoull(argv[++i], nullptr, 10); + // E2d: how far back the pinned masternode-set anchor may be before the + // daemonless bridge refuses it as STALE. Raising this is an explicit + // operator decision to wait through a longer replay -- it never + // weakens a correctness check, only the "how long is too long" bound. + else if (std::strcmp(argv[i], "--embedded-mn-bridge-max") == 0 + && i + 1 < argc) + g_mn_bridge_max_blocks = static_cast( + std::strtoul(argv[++i], nullptr, 10)); else if ((std::strcmp(argv[i], "--give-author") == 0 || std::strcmp(argv[i], "--dev-donation") == 0) && i + 1 < argc) dev_donation = std::strtod(argv[++i], nullptr); diff --git a/src/impl/dash/coin/checkpoints/README.md b/src/impl/dash/coin/checkpoints/README.md new file mode 100644 index 000000000..57f2a11a7 --- /dev/null +++ b/src/impl/dash/coin/checkpoints/README.md @@ -0,0 +1,170 @@ + +# DASH masternode-set checkpoints — the daemonless trust anchor + +**Short version: the files in this directory are data your c2pool release +*asserts* is true. A node that cold-starts from one is trusting the release +build. That is a deliberate trade, and this page is where the cost is +written down.** + +--- + +## Why a trust anchor exists at all + +To build a DASH block template, c2pool must know which masternode is next in +the DIP-3 payment queue. Getting that wrong produces a coinbase the network +rejects (`bad-cb-payee`) — a mined block thrown away. + +Ranking the queue needs, per masternode, `scriptPayout` and +`nLastPaidHeight` (plus `registeredHeight` / PoSe heights as tiebreakers). + +Those fields are not obtainable from the P2P network: + +* the Simplified MN List (DIP-0004 `mnlistdiff`), which is the only + masternode data the P2P protocol carries, **omits `scriptPayout` and + `nLastPaidHeight` entirely**; +* neither field is committed in `merkleRootMNList`, so even if a peer + volunteered them there would be no header commitment to check them against. + +So there are exactly three ways to obtain a payout-bearing masternode set: + +| source | trust | cost | +|---|---|---| +| dashd RPC `protx list valid true` | trust your own dashd | requires running dashd — the thing daemonless mode removes | +| replay every block from DIP-3 activation | **none** | ~1.5M block bodies downloaded and replayed | +| **pinned checkpoint + forward replay** | trust the release build for the set at one height | seconds to minutes | + +This directory implements the third. The second remains available as a later +opt-in verify-mode; it is **not** built yet. + +## What is trusted, and what is not + +Verified locally, no trust required: + +* **Chain position.** The checkpoint names a `blockhash`. It is rejected + unless our own X11-PoW + DGW-validated header chain holds exactly that hash + at exactly that height. An anchor minted on another chain or fork is + refused. +* **Integrity.** A SHA-256 `digest` line commits every other line. A + truncated, hand-edited, or half-merged checkpoint is refused. + *This is an integrity check on the file, not a signature.* Anyone able to + change the source can recompute the digest. It catches accidents, not + malice. +* **Forward consistency.** Every block replayed from the anchor to the + current tip re-derives the projected payee and compares it against that + block's real coinbase. A wrong anchor desyncs within a few blocks and the + bridge fails closed permanently — it never publishes, so a wrong anchor + cannot reach a template. + +Trusted, and not checkable by any means available to the node: + +* **The membership and per-masternode payout state of the set at the + anchor height.** Nothing on the DASH P2P network can prove it. + +## Fail-closed behaviour + +There is no degraded mode. If any of the following holds, the embedded arm +refuses to serve templates and logs at `ERROR`: + +* the checkpoint is **unpinned** (a release built without an anchor); +* the payload fails to parse — bad magic, unknown key, wrong network, count + mismatch, duplicate proTxHash or collateral, missing `scriptPayout`, any + unparseable field; +* the **digest** does not match the contents; +* the **chain position** disagrees with our header chain; +* the anchor is **more than `--embedded-mn-bridge-max` blocks behind the + tip** (default 20000, ≈34 days) — a stale anchor is refused rather than + ground through; +* the forward replay hits a **gap** or a **payee desync**. + +In every one of those cases templates keep routing to the dashd fallback arm +if one is configured, and to nothing at all if one is not. No masternode +payee is ever guessed. + +## Pinning / re-pinning (release step) + +**A stale anchor that nobody notices is the failure mode.** Re-pin as part of +cutting a release: + +```sh +tools/dash/gen_mn_checkpoint.py pin --network mainnet \ + --rpc-url http://127.0.0.1:9998 --rpc-user USER --rpc-password PASS +``` + +This overwrites `dash_mn_checkpoint_mainnet.inc` in place and prints a +provenance block. **Paste that block into the release notes** — the anchor's +height, block hash, masternode count and digest belong where a user can read +them without building the source. + +The generator: + +* brackets `protx list valid true ` with `getblockcount` before and + after and refetches if the tip moved, so the set is labelled with the exact + height it is current at (a mislabelled height seeds the payment cursor + wrong, which is a served `bad-cb-payee`); +* refuses to pin if `getblockchaininfo.chain` disagrees with `--network`; +* converts `payoutAddress` to `scriptPayout` once, at release time, where a + bad address is a visible failure rather than a silent runtime degradation; +* sorts by `proTxHash` so re-pin diffs are readable and two pins of the same + set produce identical bytes. + +To re-validate a checkpoint already in the tree (do this after any rebase +that touched one, and in the release checklist): + +```sh +tools/dash/gen_mn_checkpoint.py verify \ + src/impl/dash/coin/checkpoints/dash_mn_checkpoint_mainnet.inc +``` + +An offline capture works too, for pinning from a machine that cannot reach a +dashd: + +```sh +dash-cli protx list valid true 2510000 > protx.json # on the dashd box +tools/dash/gen_mn_checkpoint.py pin --network mainnet \ + --protx-json protx.json --height 2510000 --blockhash +``` + +## File format + +Authoritative specification: the header comment of +[`../mn_checkpoint.hpp`](../mn_checkpoint.hpp). + +Summary — ASCII, LF-separated, `#` comments ignored: + +``` +c2pool-dash-mn-checkpoint/1 +network mainnet +height 2510000 +blockhash <64 hex> +source +generated +count 4123 +digest <64 hex> # SHA-256 over every other non-comment line +mn \ + \ + \ + \ + +``` + +The `.inc` files are the same text wrapped as adjacent C++ string literals so +the anchor is compiled into the binary — a daemonless cold start must not +depend on a file the operator could lose, swap, or forget to install. + +## Current state of this directory + +| file | state | +|---|---| +| `dash_mn_checkpoint_mainnet.inc` | **UNPINNED** — no anchor; daemonless DASH mainnet fails closed | +| `dash_mn_checkpoint_testnet.inc` | **UNPINNED** — no anchor; daemonless DASH testnet fails closed | + +Pinning requires RPC access to a synced dashd on the corresponding network, +which is a release-time step, not a build-time one. Until then the mechanism +is present and refuses to serve, which is the intended safe default. + +The format, the parser, the bridge and the fail-closed paths are exercised +against **real captured testnet data** — +`test/dash_mn_checkpoint_testnet_1519543.inc` (produced by this directory's +generator from a real `protx list valid true 1519543`) replayed over the real +accepted blocks 1519544–1519546 — in `test/test_dash_mn_checkpoint.cpp`. That +fixture is a 6-masternode KAT subset and is **not** a shippable anchor. diff --git a/src/impl/dash/coin/checkpoints/dash_mn_checkpoint_mainnet.inc b/src/impl/dash/coin/checkpoints/dash_mn_checkpoint_mainnet.inc new file mode 100644 index 000000000..b521af66b --- /dev/null +++ b/src/impl/dash/coin/checkpoints/dash_mn_checkpoint_mainnet.inc @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// DASH masternode-set checkpoint — mainnet — *** UNPINNED *** +// +// THIS BUILD SHIPS NO mainnet MASTERNODE-SET TRUST ANCHOR. +// +// Consequence: `--embedded-mainnet` cannot cold-start the payout-bearing +// masternode set without a dashd RPC. The node FAILS CLOSED — it logs the +// refusal at ERROR and keeps routing templates to the dashd fallback arm +// rather than guessing a masternode payee. A guessed payee is a coinbase the +// network rejects (bad-cb-payee), i.e. a lost block. +// +// To pin an anchor (release step — see +// src/impl/dash/coin/checkpoints/README.md): +// +// tools/dash/gen_mn_checkpoint.py pin --network mainnet \ +// --rpc-url http://127.0.0.1:PORT --rpc-user U --rpc-password P +// +// which OVERWRITES this file and prints the provenance block for the release +// notes. Anything below this comment block is the generated payload; an empty +// payload is the UNPINNED state the parser reports distinctly. +// +"" diff --git a/src/impl/dash/coin/checkpoints/dash_mn_checkpoint_testnet.inc b/src/impl/dash/coin/checkpoints/dash_mn_checkpoint_testnet.inc new file mode 100644 index 000000000..751f954ea --- /dev/null +++ b/src/impl/dash/coin/checkpoints/dash_mn_checkpoint_testnet.inc @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// DASH masternode-set checkpoint — testnet — *** UNPINNED *** +// +// THIS BUILD SHIPS NO testnet MASTERNODE-SET TRUST ANCHOR. +// +// Consequence: `--embedded-mainnet` cannot cold-start the payout-bearing +// masternode set without a dashd RPC. The node FAILS CLOSED — it logs the +// refusal at ERROR and keeps routing templates to the dashd fallback arm +// rather than guessing a masternode payee. A guessed payee is a coinbase the +// network rejects (bad-cb-payee), i.e. a lost block. +// +// To pin an anchor (release step — see +// src/impl/dash/coin/checkpoints/README.md): +// +// tools/dash/gen_mn_checkpoint.py pin --network testnet \ +// --rpc-url http://127.0.0.1:PORT --rpc-user U --rpc-password P +// +// which OVERWRITES this file and prints the provenance block for the release +// notes. Anything below this comment block is the generated payload; an empty +// payload is the UNPINNED state the parser reports distinctly. +// +"" diff --git a/src/impl/dash/coin/coin_state_maintainer.hpp b/src/impl/dash/coin/coin_state_maintainer.hpp index e7445dc71..f76958b41 100644 --- a/src/impl/dash/coin/coin_state_maintainer.hpp +++ b/src/impl/dash/coin/coin_state_maintainer.hpp @@ -215,7 +215,12 @@ class CoinStateMaintainer { /// wrong payee). void on_mn_list_update(std::vector> mnstates, uint32_t as_of_height = 0) { - m_have_mn = !mnstates.empty(); + // With the anti-mint latch on, a snapshot with NO height cannot arm + // MN-readiness either: an unheighted set leaves the apply cursor at 0, + // which disables apply_block's contiguity guard — the exact condition + // under which a stale queue advances silently. + m_have_mn = !mnstates.empty() + && (!m_require_seeded_mn || as_of_height != 0); m_mn_snapshot_height = as_of_height; // An authoritative (non-empty) resync clears the payee-desync latch: // the queue is trustworthy again from this snapshot forward. @@ -630,7 +635,17 @@ class CoinStateMaintainer { if (m_on_mn_reseed) m_on_mn_reseed(); return r; } - m_have_mn = !m_mn_needs_reseed && m_state.mnstates().size() != 0; + // ANTI-MINT (E2d): with the latch on, block-connect alone can NEVER + // arm MN-readiness. Absent a seed the payee set starts empty and + // m_mn_snapshot_height is 0; the first live block carrying a ProRegTx + // would otherwise register one masternode, flip size() != 0, and let + // populated() serve a template whose entire "payment queue" is that + // single accidental registration — a guessed payee, i.e. a coinbase + // the network rejects. Only an authoritative height-stamped snapshot + // (the E2c RPC seed or the E2d checkpoint bridge) may arm it. + m_have_mn = !m_mn_needs_reseed + && m_state.mnstates().size() != 0 + && (!m_require_seeded_mn || m_mn_snapshot_height != 0); if (!m_have_mn) demote(); else @@ -659,6 +674,25 @@ class CoinStateMaintainer { m_on_state_dirty = std::move(fn); } + /// ANTI-MINT LATCH (E2d, #738). When enabled, MN-readiness requires an + /// AUTHORITATIVE, HEIGHT-STAMPED snapshot — the E2c `protx list` seed or + /// the E2d checkpoint bridge — and can never be armed by leg 3 + /// (block-connect) alone. + /// + /// The hole this closes only becomes reachable once the arm can run + /// without a coin RPC. On a cold daemonless start the payee set is empty + /// and the apply cursor is 0, so apply_block's contiguity guard is + /// inactive; the first connected block containing a ProRegTx registers + /// exactly one masternode, mnstates().size() != 0 flips m_have_mn, and + /// with the tip half already live populated() starts serving templates + /// whose DIP-3 payment queue is that one accidental registration. dashd + /// rejects the resulting coinbase (bad-cb-payee) and the block is lost. + /// + /// Default OFF so existing construction sites are unaffected; main_dash + /// turns it on for the whole embedded arm. + void set_require_seeded_mn_set(bool on) { m_require_seeded_mn = on; } + bool require_seeded_mn_set() const { return m_require_seeded_mn; } + /// Wire the authoritative MN re-seed sink (main_dash points this at the /// E2c `protx list valid true` seed fetch when a coin RPC is configured). /// Invoked from on_block_connected's payee-desync fail-closed path AFTER @@ -926,6 +960,10 @@ class CoinStateMaintainer { bool m_have_mn{false}; bool m_have_tip{false}; bool m_have_mn_sml{false}; // a non-empty SML has been applied (CCbTx source) + // E2d anti-mint latch — see set_require_seeded_mn_set(). Default FALSE so + // every existing construction site (the KATs, the dashd-RPC posture) keeps + // byte-identical behaviour; main_dash opts the embedded arm in. + bool m_require_seeded_mn{false}; // Height the SML/quorum state is current at (0 = unknown/cold), tracked // off each accepted diff's cbTx.nHeight — the freshness key the #814 R1 diff --git a/src/impl/dash/coin/mn_checkpoint.hpp b/src/impl/dash/coin/mn_checkpoint.hpp new file mode 100644 index 000000000..1ad92d6a3 --- /dev/null +++ b/src/impl/dash/coin/mn_checkpoint.hpp @@ -0,0 +1,549 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +#pragma once + +/// E2d (#738): the release-pinned masternode-set CHECKPOINT — the daemonless +/// cold-start seed for the payout-bearing DMN set. +/// +/// ───────────────────────────────────────────────────────────────────────── +/// THIS IS A TRUST ANCHOR. READ THIS BEFORE YOU RELY ON IT. +/// ───────────────────────────────────────────────────────────────────────── +/// The checkpoint compiled into the binary is data that the c2pool release +/// build ASSERTS is the deterministic masternode set as of a specific block. +/// It is NOT derived from the chain by this node, and it CANNOT be +/// header-authenticated: the P2P Simplified MN List (DIP-0004 mnlistdiff) +/// omits `scriptPayout` and `nLastPaidHeight`, and neither field is committed +/// in `merkleRootMNList`. There is therefore no consensus commitment against +/// which a payout-bearing set can be checked. A node that cold-starts from +/// this checkpoint is TRUSTING THE RELEASE BUILD for the contents of that +/// set — exactly the trust posture of an assumeutxo/assumevalid anchor. +/// +/// What IS independently verified, and what is not: +/// +/// VERIFIED (locally, no trust) +/// • CHAIN POSITION. `blockhash` must equal the hash our own X11-PoW + +/// DGW-validated HeaderChain holds at `height`. A checkpoint minted on +/// another chain, another fork, or a wrong height is rejected outright. +/// • INTEGRITY. A SHA-256 digest over every non-digest line detects a +/// truncated / hand-edited / partially-applied checkpoint. (It is an +/// integrity check on the FILE, NOT a signature: anyone who can change +/// the source can recompute the digest. It catches accident, not +/// malice.) +/// • FORWARD CONSISTENCY. Every block replayed forward from `height` +/// cross-checks the anchor: MnStateMachine::apply_block projects the +/// DIP-3 payee from the anchored set and compares it against the +/// block's actual coinbase. A wrong anchor desyncs within a few blocks +/// and the bridge FAILS CLOSED — it never publishes. The anchor is +/// thus trusted at h=`height` and progressively falsifiable after it. +/// +/// NOT VERIFIED (this is the trust) +/// • The membership and per-MN payout state of the set AT `height`. +/// Nothing on the wire can prove it. If the pinned bytes are wrong in +/// a way that happens not to change the projected payee for the whole +/// bridge window, the error survives. +/// +/// The fully trustless alternative — replaying every block body from DIP-3 +/// activation (~1.5M blocks) — is deliberately NOT built here. It remains +/// available as a later opt-in verify-mode. See +/// src/impl/dash/coin/checkpoints/README.md and the README section +/// "DASH daemonless masternode-set checkpoint". +/// +/// ───────────────────────────────────────────────────────────────────────── +/// FILE FORMAT (`c2pool-dash-mn-checkpoint/1`) +/// ───────────────────────────────────────────────────────────────────────── +/// ASCII, LF-separated. Blank lines and `#` comment lines are ignored +/// everywhere EXCEPT that they are also excluded from the digest domain. +/// +/// c2pool-dash-mn-checkpoint/1 +/// network +/// height chain height the set is current AT +/// blockhash <64 lowercase hex> X11 block hash at `height` +/// source provenance (who/what produced it) +/// generated when it was produced +/// count number of `mn` lines that follow +/// digest <64 lowercase hex> SHA-256, see DIGEST DOMAIN +/// mn <18 fields> ... exactly `count` of these +/// +/// `mn` line columns (space separated, all present, `-` = absent): +/// 1 proTxHash 64 hex +/// 2 collateralHash 64 hex +/// 3 collateralIndex uint32 +/// 4 type 0 = Regular, 1 = Evo +/// 5 version uint16 (ProTxVersion) +/// 6 registeredHeight uint32 +/// 7 lastPaidHeight uint32 (0 = never; dashd's -1 clamps to 0) +/// 8 poseRevivedHeight uint32 (0 = never) +/// 9 poseBanHeight uint32 (0 = not banned) +/// 10 consecutivePayments uint32 +/// 11 revocationReason uint16 +/// 12 operatorReward uint16 internal 0..10000 fixed point +/// 13 scriptPayout hex, REQUIRED (a payee-less MN is rejected) +/// 14 scriptOperatorPayout hex or `-` +/// 15 keyIDOwner 40 hex or `-` +/// 16 keyIDVoting 40 hex or `-` +/// 17 pubKeyOperator 96 hex (BLS) or `-` +/// +/// `isValid` is DERIVED as (poseBanHeight == 0) — the same projection +/// mn_seed.hpp makes from `protx list valid true`. It is never stored. +/// +/// DIGEST DOMAIN: SHA-256 over every line of the file EXCEPT the `digest` +/// line itself and except blank/`#` comment lines, each line stripped of +/// trailing whitespace and terminated by a single '\n', in file order. The +/// rule is deliberately trivial so `tools/dash/gen_mn_checkpoint.py --verify` +/// and this parser can never disagree about what was committed. +/// +/// ───────────────────────────────────────────────────────────────────────── +/// FAIL-CLOSED CONTRACT +/// ───────────────────────────────────────────────────────────────────────── +/// parse_mn_checkpoint() returns ok=false with a human-readable `error` for +/// EVERY defect — unknown magic, wrong network, bad digest, count mismatch, +/// duplicate proTxHash, duplicate collateral outpoint, empty scriptPayout, +/// any unparseable field, or an UNPINNED (comment-only) payload. There is no +/// partial success and no "best effort" mode: a partially-loaded masternode +/// set projects a WRONG payee, and a wrong payee is a coinbase the network +/// rejects (bad-cb-payee) — i.e. a lost block. Refusing to serve is always +/// cheaper than serving a wrong payee. +/// +/// STRICTLY single-coin: src/impl/dash/coin/ only. Pure function over a +/// string — network-free, filesystem-free, KAT-pinned +/// (test/test_dash_mn_checkpoint.cpp). + +#include // MNState +#include // MnType / ProTxVersion / BLS_PUBKEY_SIZE + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dash { +namespace coin { + +/// The format tag this parser accepts. Bumping the trailing integer is a +/// BREAKING format change: an older binary must reject a newer file rather +/// than misread it, which the exact-string compare below guarantees. +inline constexpr const char* kMnCheckpointMagic = "c2pool-dash-mn-checkpoint/1"; + +struct MnCheckpoint { + bool ok{false}; + std::string error; // populated iff !ok — logged verbatim + bool unpinned{false};// payload carries no anchor at all (release TODO) + + std::string network; // "mainnet" / "testnet" / ... + uint32_t height{0}; // the set is current AS OF this height + uint256 blockhash; // must match our header chain at `height` + std::string source; // provenance string, free text + std::string generated; // ISO-8601 UTC + std::string digest; // the committed digest (lowercase hex) + + std::vector> entries; +}; + +namespace ckpt_detail { + +inline bool is_hex_n(const std::string& s, size_t n) +{ + if (s.size() != n) return false; + for (char c : s) + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') + || (c >= 'A' && c <= 'F'))) + return false; + return true; +} + +inline bool hex_to_bytes(const std::string& s, std::vector& out) +{ + if (s.size() % 2 != 0) return false; + out.clear(); + out.reserve(s.size() / 2); + for (size_t i = 0; i < s.size(); i += 2) { + int hi = -1, lo = -1; + auto nib = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + }; + hi = nib(s[i]); + lo = nib(s[i + 1]); + if (hi < 0 || lo < 0) return false; + out.push_back(static_cast((hi << 4) | lo)); + } + return true; +} + +// hash160 hex -> uint160 in INTERNAL byte order (the payload bytes ARE the +// CKeyID raw bytes; SetHex would byte-reverse them). Mirrors +// mn_seed.hpp::hash160_hex_to_uint160 exactly so an RPC seed and a +// checkpoint seed produce byte-identical MNState for the same masternode. +inline uint160 hex_to_uint160(const std::string& hex) +{ + std::vector b; + if (hex.size() != 40 || !hex_to_bytes(hex, b)) return uint160(); + return uint160(b); +} + +// Strict unsigned parse — no sign, no whitespace, no overflow, no trailing +// junk. strtoul silently accepts "-1" (wraps) and "12abc" (stops early); +// both would smuggle a wrong height into the payment-queue cursor. +inline bool parse_u64(const std::string& s, uint64_t limit, uint64_t& out) +{ + if (s.empty() || s.size() > 20) return false; + uint64_t v = 0; + for (char c : s) { + if (c < '0' || c > '9') return false; + const uint64_t d = static_cast(c - '0'); + if (v > (UINT64_MAX - d) / 10) return false; + v = v * 10 + d; + } + if (v > limit) return false; + out = v; + return true; +} + +inline std::string rstrip(std::string s) +{ + while (!s.empty() && (s.back() == '\r' || s.back() == ' ' || s.back() == '\t')) + s.pop_back(); + return s; +} + +// A line participates in the digest domain iff it is neither blank nor a +// `#` comment nor the `digest` line itself. +inline bool in_digest_domain(const std::string& line) +{ + if (line.empty()) return false; + if (line[0] == '#') return false; + if (line.rfind("digest", 0) == 0 + && (line.size() == 6 || line[6] == ' ' || line[6] == '\t')) + return false; + return true; +} + +inline std::string sha256_hex(const std::string& data) +{ + unsigned char out[CSHA256::OUTPUT_SIZE]; + CSHA256().Write(reinterpret_cast(data.data()), + data.size()) + .Finalize(out); + static const char* kHex = "0123456789abcdef"; + std::string hex; + hex.reserve(CSHA256::OUTPUT_SIZE * 2); + for (unsigned char c : out) { + hex.push_back(kHex[c >> 4]); + hex.push_back(kHex[c & 0x0f]); + } + return hex; +} + +// key + " " + value split on the FIRST run of whitespace. +inline bool split_kv(const std::string& line, std::string& key, std::string& val) +{ + const size_t sp = line.find_first_of(" \t"); + if (sp == std::string::npos) return false; + key = line.substr(0, sp); + size_t v = line.find_first_not_of(" \t", sp); + val = (v == std::string::npos) ? std::string() : line.substr(v); + return true; +} + +} // namespace ckpt_detail + +/// Compute the digest a checkpoint payload commits to. Exposed so the +/// generator's `--verify` mode and the KAT can assert the identical rule. +inline std::string mn_checkpoint_digest(const std::string& payload) +{ + std::string domain; + domain.reserve(payload.size()); + std::istringstream in(payload); + std::string raw; + while (std::getline(in, raw)) { + const std::string line = ckpt_detail::rstrip(raw); + if (!ckpt_detail::in_digest_domain(line)) continue; + domain += line; + domain += '\n'; + } + return ckpt_detail::sha256_hex(domain); +} + +/// Parse + fully validate a checkpoint payload. +/// +/// `expected_network` is the network the node is actually running +/// ("mainnet" / "testnet" / ...). A mismatch is a hard error: a testnet +/// masternode set on mainnet would project a payee that exists nowhere. +/// +/// NOTE the deliberate asymmetry with `parse_protx_list_seed`: that parser +/// takes address strings and decodes them; this one takes raw scriptPayout +/// HEX. The checkpoint is our own artifact, so there is no reason to route +/// the payee through a base58 round trip and inherit its failure modes — the +/// generator does that conversion once, at release time, where a failure is +/// a human-visible build error rather than a silent runtime degradation. +inline MnCheckpoint parse_mn_checkpoint(const std::string& payload, + const std::string& expected_network) +{ + MnCheckpoint cp; + + // ── Pre-scan: is there anything here at all? ──────────────────────── + // An UNPINNED payload (the state a fresh checkout ships in) is called + // out distinctly so the operator-facing message can say "this release + // was built without an anchor" rather than "your file is corrupt". + { + bool any_content = false; + std::istringstream in(payload); + std::string raw; + while (std::getline(in, raw)) { + const std::string l = ckpt_detail::rstrip(raw); + if (!l.empty() && l[0] != '#') { any_content = true; break; } + } + if (!any_content) { + cp.unpinned = true; + cp.error = "checkpoint is UNPINNED: this build carries no DASH" + " masternode-set anchor (see" + " src/impl/dash/coin/checkpoints/README.md — the anchor" + " is pinned at release time by" + " tools/dash/gen_mn_checkpoint.py)"; + return cp; + } + } + + bool have_magic = false, have_height = false, have_hash = false; + bool have_count = false, have_digest = false, have_network = false; + uint64_t declared_count = 0; + std::set seen_protx; + std::set> seen_collateral; + + std::istringstream in(payload); + std::string raw; + size_t lineno = 0; + + auto fail = [&cp](const std::string& why) -> MnCheckpoint& { + cp.ok = false; + cp.error = why; + cp.entries.clear(); + return cp; + }; + + while (std::getline(in, raw)) { + ++lineno; + const std::string line = ckpt_detail::rstrip(raw); + if (line.empty() || line[0] == '#') continue; + + if (!have_magic) { + if (line != kMnCheckpointMagic) + return fail("bad magic on line " + std::to_string(lineno) + + ": expected '" + kMnCheckpointMagic + + "', got '" + line + "'"); + have_magic = true; + continue; + } + + std::string key, val; + if (!ckpt_detail::split_kv(line, key, val)) + return fail("unparseable line " + std::to_string(lineno) + + ": '" + line + "'"); + + if (key == "network") { + if (have_network) return fail("duplicate 'network' line"); + cp.network = val; + have_network = true; + } else if (key == "height") { + if (have_height) return fail("duplicate 'height' line"); + uint64_t h = 0; + if (!ckpt_detail::parse_u64(val, UINT32_MAX, h)) + return fail("bad height '" + val + "'"); + if (h == 0) + return fail("height 0 is not a valid anchor (genesis has no" + " deterministic masternode list)"); + cp.height = static_cast(h); + have_height = true; + } else if (key == "blockhash") { + if (have_hash) return fail("duplicate 'blockhash' line"); + if (!ckpt_detail::is_hex_n(val, 64)) + return fail("bad blockhash '" + val + "' (want 64 hex)"); + cp.blockhash = uint256S(val); + if (cp.blockhash.IsNull()) + return fail("blockhash is all-zero — an anchor MUST name a" + " real block so its chain position can be checked"); + have_hash = true; + } else if (key == "source") { + cp.source = val; + } else if (key == "generated") { + cp.generated = val; + } else if (key == "count") { + if (have_count) return fail("duplicate 'count' line"); + if (!ckpt_detail::parse_u64(val, 1000000, declared_count)) + return fail("bad count '" + val + "'"); + have_count = true; + } else if (key == "digest") { + if (have_digest) return fail("duplicate 'digest' line"); + if (!ckpt_detail::is_hex_n(val, 64)) + return fail("bad digest '" + val + "' (want 64 hex)"); + cp.digest = val; + for (auto& c : cp.digest) c = static_cast(std::tolower(c)); + have_digest = true; + } else if (key == "mn") { + if (!have_magic || !have_height || !have_hash || !have_count + || !have_network) + return fail("'mn' record on line " + std::to_string(lineno) + + " before the header block was complete"); + + std::vector f; + { + std::istringstream fs(val); + std::string tok; + while (fs >> tok) f.push_back(tok); + } + if (f.size() != 17) + return fail("mn record on line " + std::to_string(lineno) + + " has " + std::to_string(f.size()) + + " fields, want 17"); + + MNState mn; + if (!ckpt_detail::is_hex_n(f[0], 64)) + return fail("mn line " + std::to_string(lineno) + + ": bad proTxHash '" + f[0] + "'"); + const uint256 protx = uint256S(f[0]); + if (!ckpt_detail::is_hex_n(f[1], 64)) + return fail("mn line " + std::to_string(lineno) + + ": bad collateralHash '" + f[1] + "'"); + mn.collateralOutpoint.hash = uint256S(f[1]); + + uint64_t v = 0; + auto num = [&](size_t idx, uint64_t limit, const char* what, + uint64_t& dst) -> bool { + if (!ckpt_detail::parse_u64(f[idx], limit, v)) { + fail("mn line " + std::to_string(lineno) + ": bad " + what + + " '" + f[idx] + "'"); + return false; + } + dst = v; + return true; + }; + uint64_t tmp = 0; + if (!num(2, UINT32_MAX, "collateralIndex", tmp)) return cp; + mn.collateralOutpoint.index = static_cast(tmp); + if (!num(3, 1, "type", tmp)) return cp; + mn.nType = (tmp == 1) ? vendor::MnType::EVO : vendor::MnType::REGULAR; + if (!num(4, UINT16_MAX, "version", tmp)) return cp; + mn.nVersion = static_cast(tmp); + if (!num(5, UINT32_MAX, "registeredHeight", tmp)) return cp; + mn.nRegisteredHeight = static_cast(tmp); + if (!num(6, UINT32_MAX, "lastPaidHeight", tmp)) return cp; + mn.nLastPaidHeight = static_cast(tmp); + if (!num(7, UINT32_MAX, "poseRevivedHeight", tmp)) return cp; + mn.nPoSeRevivedHeight = static_cast(tmp); + if (!num(8, UINT32_MAX, "poseBanHeight", tmp)) return cp; + mn.nPoSeBanHeight = static_cast(tmp); + if (!num(9, UINT32_MAX, "consecutivePayments", tmp)) return cp; + mn.nConsecutivePayments = static_cast(tmp); + if (!num(10, UINT16_MAX, "revocationReason", tmp)) return cp; + mn.nRevocationReason = static_cast(tmp); + if (!num(11, 10000, "operatorReward", tmp)) return cp; + mn.nOperatorReward = static_cast(tmp); + + // THE payee keystone. Empty is NOT tolerated: an MN with no + // payout script still occupies a slot in the DIP-3 queue, so a + // blank one would make our projection disagree with dashd's at + // the moment it is scheduled — a served bad-cb-payee. + if (f[12] == "-" || f[12].empty() + || !ckpt_detail::hex_to_bytes(f[12], mn.scriptPayout.m_data) + || mn.scriptPayout.m_data.empty()) + return fail("mn line " + std::to_string(lineno) + + ": missing/undecodable scriptPayout '" + f[12] + + "' (fail-closed: a payee-less MN would project" + " a wrong payee)"); + if (f[13] != "-" + && !ckpt_detail::hex_to_bytes(f[13], mn.scriptOperatorPayout.m_data)) + return fail("mn line " + std::to_string(lineno) + + ": bad scriptOperatorPayout '" + f[13] + "'"); + if (f[14] != "-") { + if (!ckpt_detail::is_hex_n(f[14], 40)) + return fail("mn line " + std::to_string(lineno) + + ": bad keyIDOwner '" + f[14] + "'"); + mn.keyIDOwner = ckpt_detail::hex_to_uint160(f[14]); + } + if (f[15] != "-") { + if (!ckpt_detail::is_hex_n(f[15], 40)) + return fail("mn line " + std::to_string(lineno) + + ": bad keyIDVoting '" + f[15] + "'"); + mn.keyIDVoting = ckpt_detail::hex_to_uint160(f[15]); + } + if (f[16] != "-") { + std::vector pk; + if (!ckpt_detail::is_hex_n(f[16], vendor::BLS_PUBKEY_SIZE * 2) + || !ckpt_detail::hex_to_bytes(f[16], pk)) + return fail("mn line " + std::to_string(lineno) + + ": bad pubKeyOperator '" + f[16] + "'"); + for (size_t i = 0; i < vendor::BLS_PUBKEY_SIZE; ++i) + mn.pubKeyOperator[i] = pk[i]; + } + + // DERIVED, never stored — same projection mn_seed.hpp makes. + mn.isValid = (mn.nPoSeBanHeight == 0); + + if (!seen_protx.insert(protx).second) + return fail("duplicate proTxHash " + protx.GetHex().substr(0, 16) + + " on line " + std::to_string(lineno)); + // MnStateMachine::load() keys a collateral index by outpoint, so + // duplicates would silently shadow each other there. DIP-3 + // consensus makes collaterals unique; a duplicate means this is + // not a real masternode set. + if (!seen_collateral.emplace(mn.collateralOutpoint.hash, + mn.collateralOutpoint.index).second) + return fail("duplicate collateral outpoint on line " + + std::to_string(lineno) + + " — not a real DIP-3 set"); + + cp.entries.emplace_back(protx, std::move(mn)); + } else { + // Unknown keys are REJECTED, not skipped: a future format may + // add a semantically load-bearing key, and an old binary that + // ignored it would load a set it does not actually understand. + return fail("unknown key '" + key + "' on line " + + std::to_string(lineno) + + " (format is " + kMnCheckpointMagic + ")"); + } + } + + if (!have_magic) return fail("empty/headerless checkpoint payload"); + if (!have_network) return fail("missing 'network' line"); + if (!have_height) return fail("missing 'height' line"); + if (!have_hash) return fail("missing 'blockhash' line"); + if (!have_count) return fail("missing 'count' line"); + if (!have_digest) return fail("missing 'digest' line"); + + if (cp.network != expected_network) + return fail("network mismatch: checkpoint is for '" + cp.network + + "' but this node is running '" + expected_network + "'"); + + if (cp.entries.size() != declared_count) + return fail("count mismatch: header declares " + + std::to_string(declared_count) + " masternodes, found " + + std::to_string(cp.entries.size()) + + " (truncated or over-long checkpoint)"); + + if (cp.entries.empty()) + return fail("checkpoint declares 0 masternodes — an empty set cannot" + " back a payee (fail-closed)"); + + const std::string actual = mn_checkpoint_digest(payload); + if (actual != cp.digest) + return fail("DIGEST MISMATCH: checkpoint declares " + cp.digest + + " but its contents hash to " + actual + + " — the checkpoint has been edited or truncated;" + " regenerate it with tools/dash/gen_mn_checkpoint.py"); + + cp.ok = true; + return cp; +} + +} // namespace coin +} // namespace dash diff --git a/src/impl/dash/coin/mn_checkpoint_lane.hpp b/src/impl/dash/coin/mn_checkpoint_lane.hpp new file mode 100644 index 000000000..fa13ed1c9 --- /dev/null +++ b/src/impl/dash/coin/mn_checkpoint_lane.hpp @@ -0,0 +1,444 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +#pragma once + +/// E2d (#738): the daemonless MN-set BRIDGE — pinned checkpoint, then forward +/// replay through the existing block-connect ingest. +/// +/// ───────────────────────────────────────────────────────────────────────── +/// WHAT PROBLEM THIS SOLVES +/// ───────────────────────────────────────────────────────────────────────── +/// NodeCoinState::populated() needs BOTH a header tip AND a payout-bearing +/// masternode set. E2a gave the tip half daemonlessly. The MN half had +/// exactly one source — dashd RPC `protx list valid true` (E2c, mn_seed.hpp) +/// — because the P2P Simplified MN List omits scriptPayout/nLastPaidHeight. +/// With no RPC the arm printed "seed UNAVAILABLE" and every template kept +/// routing to the dashd fallback. That was the last structurally +/// daemon-dependent input on the daemonless path. +/// +/// This lane replaces the RPC with a release-pinned checkpoint +/// (mn_checkpoint.hpp) plus a forward replay of the blocks between the +/// checkpoint height and the current tip. +/// +/// ───────────────────────────────────────────────────────────────────────── +/// WHY A PRIVATE MnStateMachine AND NOT "SEED THEN LET THE MAINTAINER RUN" +/// ───────────────────────────────────────────────────────────────────────── +/// The obvious wiring — publish the checkpoint straight into +/// Node::mn_list_update at h=CKPT and let CoinStateMaintainer fold blocks +/// forward — is WRONG, and dangerously so. +/// +/// MnStateMachine::apply_block is forward-CONTIGUOUS: after loading a +/// snapshot at h, the only block it may fold is h+1. During a cold start the +/// live feed is simultaneously delivering blocks at the CURRENT TIP (the +/// new_block inv -> request_block leg). Publishing at h=CKPT would arm +/// MN-readiness immediately, and the first live tip block (h >> CKPT+1) would +/// trip gap_detected -> wipe + demote + request an authoritative re-seed. On +/// the daemonless arm there IS no authoritative re-seed, so the arm would +/// latch dead. Worse, between the publish and that gap the bundle is +/// populated() with a payment queue that is thousands of blocks stale — +/// a served bad-cb-payee window. +/// +/// So the bridge runs OFF to the side, on a private MnStateMachine this lane +/// owns. It applies ONLY the exact next height it is waiting for; live tip +/// blocks that arrive mid-bridge are ignored (they will be requested in +/// sequence later). The maintainer sees nothing until the private machine has +/// caught up to the header tip, at which point the lane publishes the +/// finished set through the SAME leg-4 event (Node::mn_list_update) that the +/// E2c RPC seed uses — so the maintainer takes it exactly like any other +/// authoritative resync, with as_of_height = the height it actually bridged +/// to, and its snapshot fence set correctly for the next live block. +/// +/// ───────────────────────────────────────────────────────────────────────── +/// THE BRIDGE IS ALSO THE ANCHOR'S VERIFICATION +/// ───────────────────────────────────────────────────────────────────────── +/// Each replayed block is a live cross-check of the pinned data: +/// apply_block projects the DIP-3 payee from the anchored set and compares it +/// with that block's ACTUAL coinbase. An accepted block by definition pays +/// dashd's projected masternode, so a mismatch proves the anchor (or our +/// replay) is wrong. Any payee_desync or gap_detected during the bridge is +/// TERMINAL here: the lane fails closed permanently and never publishes. +/// A wrong checkpoint therefore cannot reach a template — it can only stop +/// the embedded arm from arming. +/// +/// This does NOT make the anchor trustless. It makes it progressively +/// falsifiable: the further the bridge runs, the more payment slots have been +/// confirmed against real coinbases. The set membership AT the checkpoint +/// height is still trusted. See mn_checkpoint.hpp's header and +/// src/impl/dash/coin/checkpoints/README.md. +/// +/// ───────────────────────────────────────────────────────────────────────── +/// THREADING / LOCK POSTURE (see the #878/#881 caller-side-lock defect class) +/// ───────────────────────────────────────────────────────────────────────── +/// This lane holds NO lock of its own and is NOT thread-safe. It is confined +/// to the single io_context thread that main_dash runs, exactly like UtxoLane +/// and CoinStateMaintainer's reception paths. Its two entry points are: +/// +/// • on_block_connected(): driven from the block-connect ingest, which +/// fires from the coin-P2P read handler on the io thread. +/// • pump(): driven from HeaderChain's on_tip_changed callback and from +/// on_block_connected itself. +/// +/// pump() calls back into HeaderChain (height() / get_header_by_height()), +/// which are SELF-LOCKING on HeaderChain::m_mutex. That is safe — and, +/// critically, REACHABLE — because HeaderChain dispatches m_on_tip_changed +/// with m_mutex RELEASED: add_header() and add_headers() both copy the +/// pending tip-change out inside the lock scope, close the scope, and only +/// then invoke the callback (header_chain.hpp: the callback calls at the end +/// of add_header/add_headers sit outside every lock_guard block). The +/// pre-existing tip-changed lambda in main_dash already relies on this — it +/// calls tip_advance_from_chain(*hc, ...), which takes the same lock — and +/// that path is proven live. There is no exclusive lock held across any call +/// into this lane. +/// +/// FENCED: src/impl/dash only. Constructed exclusively by the opt-in embedded +/// path in main_dash.cpp; the dashd-RPC fallback never touches this file. + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace dash { +namespace coin { + +class MnCheckpointLane { +public: + /// Request the full block body at `height` from the coin-P2P peer. Wired + /// by main_dash to the same header-chain-hash lookup + request_block seam + /// UtxoLane uses. + using RequestBlockFn = std::function; + /// Publish the bridged, payout-bearing set as an authoritative MN-list + /// resync. Wired by main_dash to Node::mn_list_update.happened(). + using PublishFn = + std::function>, uint32_t)>; + /// Current best header height (0 = header chain empty). + using TipHeightFn = std::function; + /// Block hash our header chain holds at `height`, if known. + using HeaderHashAtFn = std::function(uint32_t)>; + + enum class State { + Unarmed, ///< no checkpoint loaded — the lane does nothing + Waiting, ///< armed; header chain has not reached the anchor yet + Bridging, ///< replaying blocks anchor+1 .. tip + Published, ///< set handed to the maintainer; lane is done + FailedClosed, ///< terminal refusal — the arm must NOT serve templates + }; + + /// ~34 days of DASH blocks at 2.5 min spacing. Chosen so a release cut + /// within the last month bridges in minutes over the coin-P2P feed, and + /// anything older is refused loudly instead of grinding. + static constexpr uint32_t kDefaultMaxBridgeBlocks = 20000; + + /// How many block bodies to keep requested ahead of the apply cursor. + /// Responses to getdata come back in request order on the single ordered + /// TCP stream, so a window preserves the contiguity apply_block demands + /// while avoiding a per-block round trip. Matches the order of magnitude + /// of UtxoLane's 288-block bootstrap window without competing with it. + static constexpr uint32_t kWindow = 64; + + MnCheckpointLane() = default; + MnCheckpointLane(const MnCheckpointLane&) = delete; + MnCheckpointLane& operator=(const MnCheckpointLane&) = delete; + + void set_request_block_fn(RequestBlockFn fn) { m_request = std::move(fn); } + void set_publish_fn(PublishFn fn) { m_publish = std::move(fn); } + void set_tip_height_fn(TipHeightFn fn) { m_tip_height = std::move(fn); } + void set_header_hash_at_fn(HeaderHashAtFn fn){ m_header_hash_at = std::move(fn); } + + /// Maximum number of blocks the bridge is willing to replay. A checkpoint + /// further behind the tip than this is treated as STALE and refused: the + /// replay would take longer than an operator would tolerate, and a + /// silently-crawling bridge that never arms is exactly the "quiet + /// degradation" failure this lane exists to avoid. Refusing loudly points + /// the operator at the real fix (upgrade to a release with a fresher + /// anchor, or run with a coin RPC). + void set_max_bridge_blocks(uint32_t n) { m_max_bridge = n; } + uint32_t max_bridge_blocks() const { return m_max_bridge; } + + /// Load a parsed checkpoint. A !ok checkpoint arms the lane in the + /// terminal FailedClosed state so the refusal is visible in status() + /// rather than looking like "not configured". + void arm(const MnCheckpoint& cp) + { + if (!cp.ok) { + m_state = State::FailedClosed; + m_status = cp.unpinned + ? ("no MN-set anchor in this build: " + cp.error) + : ("checkpoint REJECTED: " + cp.error); + LOG_ERROR << "[MN-CKPT] " << m_status; + return; + } + m_anchor_height = cp.height; + m_anchor_hash = cp.blockhash; + m_anchor_source = cp.source; + m_anchor_count = cp.entries.size(); + m_machine.load(cp.entries, cp.height); + m_next = cp.height + 1; + m_state = State::Waiting; + m_status = "armed at h=" + std::to_string(cp.height) + " (" + + std::to_string(m_anchor_count) + " masternodes), waiting" + " for headers to reach the anchor"; + LOG_INFO << "[MN-CKPT] armed: anchor h=" << cp.height << " " + << cp.blockhash.GetHex().substr(0, 16) << " count=" + << m_anchor_count << " source='" << cp.source << "'"; + } + + State state() const { return m_state; } + const std::string& status() const { return m_status; } + uint32_t anchor_height() const { return m_anchor_height; } + uint32_t cursor_height() const { return m_next == 0 ? 0 : m_next - 1; } + size_t set_size() const { return m_machine.size(); } + bool failed_closed() const { return m_state == State::FailedClosed; } + bool published() const { return m_state == State::Published; } + + /// Drive the bridge: verify the anchor's chain position once the header + /// chain reaches it, enforce the staleness bound, request the next window + /// of block bodies, and publish when the cursor catches the tip. + /// + /// Safe to call as often as convenient (every tip change is ideal); it is + /// idempotent and cheap when there is nothing to do. + void pump() + { + if (m_state != State::Waiting && m_state != State::Bridging) return; + if (!m_tip_height) return; + + const uint32_t tip = m_tip_height(); + if (tip == 0 || tip < m_anchor_height) { + // Headers still syncing towards the anchor. Not an error — but say + // so periodically, because "silently waiting forever" and "working" + // look identical from outside. + if (tip / 10000 != m_last_wait_log / 10000) { + m_last_wait_log = tip; + LOG_INFO << "[MN-CKPT] waiting: header tip h=" << tip + << " has not reached the anchor h=" << m_anchor_height; + } + return; + } + + // ── Chain-position verification (locally verified, no trust) ────── + // Do this the moment the header chain covers the anchor height, and + // only once. If our PoW-validated header chain says a different block + // occupies that height, the checkpoint is for a different chain or a + // different fork; nothing built on it can be right. + if (!m_position_verified) { + if (!m_header_hash_at) { + return fail_closed("no header-hash lookup wired — cannot verify" + " the checkpoint's chain position"); + } + auto have = m_header_hash_at(m_anchor_height); + if (!have) { + // Height is covered but the entry is not retrievable (pruned + // index / still loading). Retry on the next pump. + return; + } + if (*have != m_anchor_hash) { + return fail_closed( + "CHAIN-POSITION MISMATCH at h=" + + std::to_string(m_anchor_height) + ": checkpoint pins " + + m_anchor_hash.GetHex() + + " but our PoW-validated header chain holds " + + have->GetHex() + + " — this anchor is for a different chain/fork"); + } + m_position_verified = true; + LOG_INFO << "[MN-CKPT] chain position VERIFIED: anchor h=" + << m_anchor_height << " matches our header chain"; + } + + // ── Staleness bound ────────────────────────────────────────────── + const uint32_t distance = tip - m_anchor_height; + if (distance > m_max_bridge) { + return fail_closed( + "checkpoint is STALE: anchor h=" + std::to_string(m_anchor_height) + + " is " + std::to_string(distance) + " blocks behind the tip h=" + + std::to_string(tip) + ", over the " + + std::to_string(m_max_bridge) + "-block bridge limit." + " Upgrade to a release with a fresher masternode-set anchor," + " raise --embedded-mn-bridge-max, or run with a coin RPC" + " (--coin-rpc-*) so the authoritative protx seed is used"); + } + + if (m_state == State::Waiting) { + m_state = State::Bridging; + LOG_INFO << "[MN-CKPT] bridge START: replaying h=" << m_next + << ".." << tip << " (" << (tip - m_next + 1) + << " blocks) onto the anchored set"; + } + + // Cursor already at (or past) the tip: the bridge is complete. + if (m_next > tip) { publish(tip); return; } + + // Stall detection. pump() is driven by tip changes (~2.5 min apart on + // mainnet), so "the cursor has not moved since the last pump" means a + // getdata response was dropped. Re-request from the cursor rather than + // waiting forever with no symptom other than "the arm never armed". + if (m_next == m_last_pump_next) { + ++m_stalled_pumps; + m_rerequest_from_cursor = true; + if ((m_stalled_pumps % 5) == 0) + LOG_WARNING << "[MN-CKPT] bridge stalled at h=" << m_next + << " across " << m_stalled_pumps + << " drive attempts — re-requesting from the cursor"; + } else { + m_stalled_pumps = 0; + } + m_last_pump_next = m_next; + + request_window(tip); + } + + /// Fold the next bridge block. Wired to the SAME block-connect ingest the + /// maintainer uses; blocks that are not the exact next height are ignored + /// (a live tip block arriving mid-bridge must not be folded onto a stale + /// cursor — that is the E4-soak bad-cb-payee shape). + void on_block_connected(const BlockType& block, uint32_t height) + { + if (m_state != State::Bridging) return; + if (height != m_next) return; + + const auto r = m_machine.apply_block(block, height); + + // The anchor's own falsification test. apply_block already logs the + // detail; here it is TERMINAL (unlike the maintainer's path, which can + // ask for an RPC re-seed — daemonless has nothing to re-seed from, and + // guessing is exactly what must never happen). + if (r.gap_detected || r.payee_desync) { + return fail_closed( + std::string("bridge replay ") + + (r.gap_detected ? "GAP" : "PAYEE DESYNC") + " at h=" + + std::to_string(height) + + " — the pinned masternode-set anchor does not reproduce this" + " block's actual coinbase payee. The anchor is wrong or the" + " replay is incomplete; refusing to publish a masternode set" + " that would mint a rejected coinbase"); + } + if (r.total_after == 0) { + return fail_closed( + "bridge replay emptied the masternode set at h=" + + std::to_string(height) + " — cannot back a payee"); + } + + m_next = height + 1; + ++m_applied; + m_stalled_pumps = 0; + + if ((m_applied % 500) == 0) { + LOG_INFO << "[MN-CKPT] bridge progress: applied " << m_applied + << " blocks, cursor h=" << height << " set=" + << r.total_after; + } + + if (!m_tip_height) return; + const uint32_t tip = m_tip_height(); + if (m_next > tip) { publish(tip); return; } + request_window(tip); + } + +private: + void request_window(uint32_t tip) + { + if (!m_request) { + return fail_closed("no block-request seam wired — the bridge cannot" + " fetch the blocks it must replay"); + } + const uint32_t end = std::min(m_next + kWindow - 1, tip); + // Ask only for heights not already asked for, so a 20k-block bridge + // issues ~20k getdata rather than 20k x kWindow. A detected stall + // (pump()) forces one re-request pass from the cursor; getdata for a + // block we already hold is harmless — apply_block skips it. + uint32_t from = m_next; + if (!m_rerequest_from_cursor && m_requested_through >= m_next) + from = m_requested_through + 1; + m_rerequest_from_cursor = false; + // Status BEFORE the requests: a request may be answered synchronously + // (an ordered stream, or a test harness), and the answer can publish or + // fail the bridge — whose status must not then be overwritten by ours. + m_status = "bridging: cursor h=" + std::to_string(m_next) + " target h=" + + std::to_string(tip); + for (uint32_t h = from; h <= end; ++h) { + // Same reason: a synchronous answer may have finished or failed the + // bridge mid-loop. Never keep pulling history for a lane that is no + // longer bridging. + if (m_state != State::Bridging) return; + m_request(h); + if (h > m_requested_through) m_requested_through = h; + } + } + + void publish(uint32_t bridged_to) + { + if (!m_publish) { + return fail_closed("no publish seam wired — the bridged masternode" + " set has nowhere to go"); + } + const uint32_t as_of = m_next - 1; + std::vector> out; + out.reserve(m_machine.entries().size()); + for (const auto& [h, st] : m_machine.entries()) out.emplace_back(h, st); + if (out.empty()) { + return fail_closed("bridged masternode set is empty — refusing to" + " publish"); + } + m_state = State::Published; + m_status = "published " + std::to_string(out.size()) + + " masternodes as-of h=" + std::to_string(as_of) + + " (anchor h=" + std::to_string(m_anchor_height) + + " + " + std::to_string(m_applied) + " replayed blocks)"; + LOG_INFO << "[MN-CKPT] bridge COMPLETE: " << out.size() + << " masternodes as-of h=" << as_of << " (anchor h=" + << m_anchor_height << ", replayed " << m_applied + << " blocks, tip h=" << bridged_to + << ") -> publishing to the maintainer"; + m_publish(std::move(out), as_of); + } + + void fail_closed(const std::string& why) + { + if (m_state == State::FailedClosed) return; + m_state = State::FailedClosed; + m_status = why; + // Deliberately ERROR, not WARNING: the operator's embedded arm will + // not arm, and the only thing worse than saying so is not saying so. + LOG_ERROR << "[MN-CKPT] FAIL-CLOSED: " << why; + LOG_ERROR << "[MN-CKPT] the embedded DASH template arm will NOT serve;" + " templates keep routing to the dashd fallback arm (if" + " configured). No masternode payee will be guessed."; + } + + MnStateMachine m_machine; + + RequestBlockFn m_request; + PublishFn m_publish; + TipHeightFn m_tip_height; + HeaderHashAtFn m_header_hash_at; + + State m_state{State::Unarmed}; + std::string m_status{"unarmed"}; + + uint32_t m_anchor_height{0}; + uint256 m_anchor_hash; + std::string m_anchor_source; + size_t m_anchor_count{0}; + + uint32_t m_next{0}; // the ONLY height apply_block may fold next + uint32_t m_applied{0}; + uint32_t m_max_bridge{kDefaultMaxBridgeBlocks}; + uint32_t m_stalled_pumps{0}; + uint32_t m_last_pump_next{0}; // cursor at the previous pump (stall probe) + uint32_t m_requested_through{0}; // highest height already asked for + bool m_rerequest_from_cursor{false}; + uint32_t m_last_wait_log{0}; + bool m_position_verified{false}; +}; + +} // namespace coin +} // namespace dash diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 8009aecdd..a21e183ab 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -846,9 +846,20 @@ if (BUILD_TESTING AND GTest_FOUND) # fail-closed abort on undecodable payees, and the seed->leg-4-event-> # populated()-flips->embedded-template-pays-the-correct-MN capstone. Same # target/link set (core already carries address_utils); no new allowlist. + # Fourth TU (E2d): test_dash_mn_checkpoint.cpp — the release-pinned MN-set + # checkpoint (mn_checkpoint.hpp) + its forward-replay bridge + # (mn_checkpoint_lane.hpp): format/digest/fail-closed parsing, equivalence + # with the E2c RPC seed on the same captured dashd JSON, the real + # 1519544..1519546 replay, every terminal fail-closed path, and the + # end-to-end publish -> leg-4 -> populated() flip with negative controls. + # FOLDED into this EXISTING allowlisted target on purpose — a new + # add_executable would not be in the build.yml --target list and would + # silently report "Not Run" (#143 NOT_BUILT trap). No #ifdef guards in that + # TU either, so every registered case actually executes (#895). add_executable(test_dash_node_reception_wire test_dash_node_reception_wire.cpp test_dash_live_feed_bridge.cpp - test_dash_mn_seed.cpp) + test_dash_mn_seed.cpp + test_dash_mn_checkpoint.cpp) target_link_libraries(test_dash_node_reception_wire PRIVATE GTest::gtest_main GTest::gtest dash_x11 core diff --git a/test/dash_mn_checkpoint_testnet_1519543.inc b/test/dash_mn_checkpoint_testnet_1519543.inc new file mode 100644 index 000000000..499e95f99 --- /dev/null +++ b/test/dash_mn_checkpoint_testnet_1519543.inc @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// GENERATED FILE -- DO NOT EDIT BY HAND. +// Produced by tools/dash/gen_mn_checkpoint.py; the trailing `digest` line +// commits every other line, so a hand edit is rejected at load time. +// +// THIS IS A TRUST ANCHOR. See src/impl/dash/coin/checkpoints/README.md and +// the "DASH daemonless masternode-set checkpoint" section of README.md. +// + +"c2pool-dash-mn-checkpoint/1\n" +"network testnet\n" +"height 1519543\n" +"blockhash 00000048ba417fd364d4220de99d16d6e17651a565b9b529417905b99b3a541b\n" +"source dashd testnet capture (test/test_dash_mn_seed.cpp kProtxFront6At1519543): protx list valid true 1519543 - 6-MN KAT subset, NOT a shippable anchor\n" +"generated 2026-07-26T13:23:51Z\n" +"count 6\n" +"digest eddc3386874eedd5f0e7619f198090390be13e9f5c9893512b629462a9953c91\n" +"mn 72ee70fa75262781a17d1eb69a6c3e97328208be98b59d5530164f31e481d3aa 4ee3ff5074723d995f4cb957a954587c6c637a42655ada8f4054037b28d1e7a8 96 0 1 838365 1519462 1367330 0 0 0 0 76a91464f2b2b84f62d68a2cd7f7f5fb2b5aa75ef716d788ac - 889e8cd2ab0cc70e36e566648e265c7536a8fcaf 889e8cd2ab0cc70e36e566648e265c7536a8fcaf 91f9052f62561db112ddca7df3d914d546866b130124eccb2ae1e8419563e51f239b2efec3d1b3fd388072610939d694\n" +"mn 91bbce94c34ebde0d099c0a2cb7635c0c31425ebabcec644f4f1a0854bfa605d 6ce8545e25d4f03aba1527062d9583ae01827c65b234bd979aca5954c6ae3a59 30 1 2 850334 1519461 1368973 0 0 0 0 76a914c69a0bda7daaae481be8def95e5f347a1d00a4b488ac - cf83406065cb65ce74100f3c35a0324bfc92dcb0 cf83406065cb65ce74100f3c35a0324bfc92dcb0 81ad0f9be5a88ae62ff54fe938dfceea71be03bd4c6a7aebf75896e8d495d310acc4146aa4820bc0e5f5b06579dedea5\n" +"mn 9b653e767b978c10346d938c08dc8c5acd03c495f9d913e6fc652bfcae11a348 75fe9d8d90619576ef11deb8550d023366bf9d85e686dc6d5afba0aca8827e21 2 1 2 1427623 1519460 1446613 0 0 0 0 76a914c69a0bda7daaae481be8def95e5f347a1d00a4b488ac - da3d9736bf16f34dcea4be38d0eb404335ee21c8 da3d9736bf16f34dcea4be38d0eb404335ee21c8 80ab2701950ef7a1e068b97d02a2ed1e64a8d38dd2d0e5a0d9de5432b3e1ad2c318109d89326d48ab6e285d6ac0bb22e\n" +"mn c87218fb9d031f4926c22430c69b4edf1f0fb80c331c1a79e3b1b3873407c0ac 4ee3ff5074723d995f4cb957a954587c6c637a42655ada8f4054037b28d1e7a8 62 0 1 838365 1519463 1367840 0 0 0 0 76a91464f2b2b84f62d68a2cd7f7f5fb2b5aa75ef716d788ac - 5c0c201c793d6cb7265b6897058b0b1e75fdf528 5c0c201c793d6cb7265b6897058b0b1e75fdf528 842c53c3aa11ae4b985a52ae6a3170bdb58f88ec04c62013f9322bd5fda4417939836b6f41741dd864c348103a1155d3\n" +"mn dc2e02ac95ce4ccc9843c38de7bdaf32f2a1d5966c054127a3f4ca4f4bbd5991 4ee3ff5074723d995f4cb957a954587c6c637a42655ada8f4054037b28d1e7a8 34 0 1 838365 1519459 1367840 0 0 0 0 76a91464f2b2b84f62d68a2cd7f7f5fb2b5aa75ef716d788ac - 32eb51df8032392d44594907e5032d23df570b2d 32eb51df8032392d44594907e5032d23df570b2d 0db85a27cd589d225beff9977aa0ac32551d15bd906a899bc1ef33458d7c979118f92bf1de4ddb55144acc2f7cf6d854\n" +"mn ecebeb952f56a61abaccd7bda7f4df5eccbd5f87a91bc4a8969535df1058158e f329c4c9d194159e81597e50144ce41a40e2b40860c7813a85eabb0454700a3d 1 0 2 1491043 1519464 1507780 0 0 0 0 76a914fde02db503079a92e7e6d3716319450f6b7ee61388ac - d31e15fafbdcbd27da90927023bc9ce39525709b d26670e8037cf539d5e64246b6395ee63db90dfa 93b72005436c6e4dbd6b59cf5818fd9679a4a2c51548fb9205c88852b4bb5f05d1bfc72a54035b685fceaaae2115d43f\n" +"\n" diff --git a/test/test_dash_mn_checkpoint.cpp b/test/test_dash_mn_checkpoint.cpp new file mode 100644 index 000000000..bf24ece6d --- /dev/null +++ b/test/test_dash_mn_checkpoint.cpp @@ -0,0 +1,752 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +/// E2d (#738) -- pinned masternode-set checkpoint + forward-replay bridge KAT. +/// +/// THE GAP THIS CLOSES: E2c gave the payout-bearing DMN set exactly one +/// cold-start source, dashd RPC `protx list valid true`. With no RPC the arm +/// printed "seed UNAVAILABLE" and every template kept routing to the dashd +/// fallback -- the last structurally daemon-dependent input on the daemonless +/// path. mn_checkpoint.hpp + mn_checkpoint_lane.hpp replace the RPC with a +/// release-pinned anchor replayed forward through the existing block-connect +/// ingest. +/// +/// The anchor is a TRUST ANCHOR (see mn_checkpoint.hpp and +/// src/impl/dash/coin/checkpoints/README.md). This suite pins the parts that +/// are NOT trusted -- every local check, and every way the load can refuse. +/// +/// Pinned here, network-free, against REAL captured testnet data: +/// * the committed fixture (test/dash_mn_checkpoint_testnet_1519543.inc, +/// produced by tools/dash/gen_mn_checkpoint.py from a real +/// `protx list valid true 1519543`) parses, and the resulting MNStates are +/// FIELD-IDENTICAL to what the E2c RPC parser produces from the same +/// dashd JSON -- the checkpoint seed and the RPC seed are the same set; +/// * fail-closed on every defect class: unpinned, bad magic, unknown key, +/// network mismatch, count mismatch, DIGEST mismatch, duplicate proTxHash, +/// duplicate collateral, missing scriptPayout, negative/garbage numbers; +/// * the bridge replays the REAL accepted blocks 1519544..1519546 onto the +/// anchored set and publishes at the tip with dashd-exact lastPaidHeights; +/// * chain-position mismatch, staleness over the bridge bound, a replay gap +/// and a replay payee-desync each fail closed TERMINALLY (never publish); +/// * end-to-end: the bridge's publish flows through the REAL leg-4 event +/// (mn_list_update -> wire_mn_list_ingest -> on_mn_list_update) and, with +/// a live tip, flips populated() so select_work() takes the EMBEDDED arm; +/// * NEGATIVE CONTROL: the same end-to-end wiring with a corrupted anchor +/// leaves populated() FALSE and select_work() on the fallback. +/// +/// #895 note: NOTHING in this file is #ifdef-guarded. Every TEST() below is +/// unconditionally compiled and registered, so a green tick here means the +/// bodies actually ran. + +#include + +#include // parse_mn_checkpoint (DUT) +#include // MnCheckpointLane (DUT) +#include // parse_protx_list_seed (E2c cross-check) +#include +#include // wire_mn_list_ingest (leg 4) +#include // wire_tip_ingest (leg 2) +#include +#include + +#include +#include + +#include + +#include +#include +#include +#include +#include + +using dash::coin::MNState; +using dash::coin::MnCheckpoint; +using dash::coin::MnCheckpointLane; +using dash::coin::mn_checkpoint_digest; +using dash::coin::parse_mn_checkpoint; + +namespace { + +constexpr uint8_t TESTNET_PUBKEY_VER = 140; +constexpr uint8_t TESTNET_P2SH_VER = 19; + +// ── The real captured anchor ─────────────────────────────────────────────── +// Generated by tools/dash/gen_mn_checkpoint.py from dashd testnet +// `protx list valid true 1519543`. Same 6 masternodes the E2c suite's +// kProtxFront6At1519543 carries; blockhash is block 1519544's hashPrevBlock. +const char* const kRealCheckpoint1519543 = +#include "dash_mn_checkpoint_testnet_1519543.inc" + ; + +// Height + hash the anchor pins, restated so a fixture regeneration that +// silently moved either is caught here rather than in the bridge. +constexpr uint32_t kAnchorHeight = 1519543; +const char* const kAnchorHash = + "00000048ba417fd364d4220de99d16d6e17651a565b9b529417905b99b3a541b"; + +// Real accepted testnet blocks 1519544..1519546. Same bodies the E2c suite +// replays; kept here so this TU is self-contained (the two suites link into +// the same target, so a shared symbol would collide). +const char* const kBlockHex1519544 = + "000000201b543a9bb905794129b5b965a55176e1d6169de90d22d464d37f41ba4800000095d95cbd42771d55f34c46009d29" + "27f0684b22ca85667a2c0cb1626b0952138dfcc9606a4518511d0002780e0103000500010000000000000000000000000000" + "000000000000000000000000000000000000ffffffff1303b82f170e2f5032506f6f6c2d74444153482fffffffff05056583" + "03000000001976a9142629e1bbb4960da4c86226c876019e55c7c0346288ac2ed5fd0300000000016af80da7060000000019" + "76a91464f2b2b84f62d68a2cd7f7f5fb2b5aa75ef716d788acb3e60800000000001976a91420cb5c22b1e4d5947e5c112c76" + "96b51ad9af3c6188ac00000000000000002a6a282ad8f902c1ae2fece365590e4009eda31825336f273077a41697c43a54c0" + "835f000000000000000000000000af0300b82f17007edd5fec21fd07f7b9d44b5029884ebc71d2e2812fd3029a76690045ce" + "5c1647eb3ef20c2f89e9b8525f21e7064a47305b867ed642db1166faa9e0461fa0ee1d01b1a377ba4ad754bcc9bc00da609a" + "d3482ad7acc7847039a0424300c35a2e99c4ea6f3659e987f86eb9559fb88a020bf404d9fe2c33e097e13647f55fbe0d6941" + "e306f7bf206728634bff7e52a49d9e4b1b969017abd30c32ebc08d0c623cd98bb460a399f41e0000"; + +const char* const kBlockHex1519545 = + "0000002013912532ebf7d4e655abf829f99d670de25fdb46d2cbcc6d567b6f294200000070151825df25a1f90162dfc2d978" + "8b7dfdc8a690f9a61f0f8dd3f93e43bb5a2234ca606ac3fd4c1d00358c470103000500010000000000000000000000000000" + "000000000000000000000000000000000000ffffffff1303b92f170e2f5032506f6f6c2d74444153482fffffffff050c6583" + "03000000001976a9142629e1bbb4960da4c86226c876019e55c7c0346288ac2ed5fd0300000000016af80da7060000000019" + "76a914c69a0bda7daaae481be8def95e5f347a1d00a4b488acace60800000000001976a91420cb5c22b1e4d5947e5c112c76" + "96b51ad9af3c6188ac00000000000000002a6a2863ff5a8eb9b8e769435056d98f1831c46eaee642b6d17d0e9148d9b8385e" + "434a000000000000000000000000af0300b92f17007edd5fec21fd07f7b9d44b5029884ebc71d2e2812fd3029a76690045ce" + "5c1647eb3ef20c2f89e9b8525f21e7064a47305b867ed642db1166faa9e0461fa0ee1d0191d1a83a98f0b7b8cfb7f7ffca11" + "6dd416960ec91ef8a0052ca0ac2ff7f0fcddf0c38f6894765f7ba11c6fa25385ca9d1498cd5d5581b9ca2603fd2d35a16dfe" + "24cfd3dd49326da6d0d371cf3f97921c471ab5ad2eba487f680ced98063b26a9e235a19df41e0000"; + +const char* const kBlockHex1519546 = + "00000020c9497b889ffb56364da48fd35c7673dc51df02c382bc2a3876b91e5138000000a2de65097455b4ae92412e868d9a" + "c9d71c6f79322c1c10ddcab218e0bf004ff4e2ca606a76b04b1d000441980403000500010000000000000000000000000000" + "000000000000000000000000000000000000ffffffff1303ba2f170e2f5032506f6f6c2d74444153482fffffffff05e16483" + "03000000001976a9142629e1bbb4960da4c86226c876019e55c7c0346288ac2ed5fd0300000000016af80da7060000000019" + "76a914c69a0bda7daaae481be8def95e5f347a1d00a4b488acd7e60800000000001976a91420cb5c22b1e4d5947e5c112c76" + "96b51ad9af3c6188ac00000000000000002a6a2867e657696ff842bd354401663fb597a5edb52be7d029fdbdc710894c9776" + "3512000000000000000000000000af0300ba2f17007edd5fec21fd07f7b9d44b5029884ebc71d2e2812fd3029a76690045ce" + "5c1647eb3ef20c2f89e9b8525f21e7064a47305b867ed642db1166faa9e0461fa0ee1d01b836c031e91cc8b43129c7b1a93b" + "2bf2e5ce31803a848318680d8144ba28379a8d769a313882306a5e3f4a10814466a511415c388a2ab98201c312e7ec291194" + "31693053caad6df9d98a187588145cea3eb42b741eb3959abdf9138167fe993a100b9fa1f41e000003000600000000000000" + "fd49010100ba2f170003000148f97725a2106d1fa6c4dc099de8acdc72b5b6b601751e65201adf9512000000320000000000" + "0000320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "000000000000000000000000000000000000000000000000000000000000000003000600000000000000fd55010100ba2f17" + "0003000448f97725a2106d1fa6c4dc099de8acdc72b5b6b601751e65201adf95120000006400000000000000000000000000" + "6400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "00000000000000000000000000000000000000000000000000000000000000000000000003000600000000000000fd430101" + "00ba2f170003000648f97725a2106d1fa6c4dc099de8acdc72b5b6b601751e65201adf951200000019000000001900000000" + "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "00000000000000000000000000000000000000000000"; + +dash::coin::BlockType block_from_hex(const char* hex) +{ + std::string h(hex); + std::vector raw; + raw.reserve(h.size() / 2); + for (size_t i = 0; i + 1 < h.size(); i += 2) + raw.push_back(static_cast( + std::stoul(h.substr(i, 2), nullptr, 16))); + ::PackStream ps(raw); + dash::coin::BlockType blk; + ps >> blk; + return blk; +} + +// Re-digest a mutated payload so a test can isolate the field it is probing +// from the digest guard (otherwise EVERY mutation would merely prove the +// digest works). +std::string reseal(std::string payload) +{ + const size_t at = payload.find("digest "); + if (at == std::string::npos) return payload; + const size_t eol = payload.find('\n', at); + std::string stripped = payload.substr(0, at) + payload.substr(eol + 1); + const std::string d = mn_checkpoint_digest(stripped); + return payload.substr(0, at) + "digest " + d + payload.substr(eol); +} + +// Replace the first occurrence of `from` with `to`, then reseal. +std::string mutate(const std::string& payload, const std::string& from, + const std::string& to, bool reseal_it = true) +{ + std::string p = payload; + const size_t at = p.find(from); + EXPECT_NE(at, std::string::npos) << "mutation anchor not found: " << from; + if (at == std::string::npos) return p; + p.replace(at, from.size(), to); + return reseal_it ? reseal(p) : p; +} + +// ── A driveable harness around the lane ──────────────────────────────────── +// Stands in for main_dash's seams: a synthetic header chain (height -> hash), +// a block store the request seam serves from, and a capture of what the lane +// publishes. +struct BridgeHarness { + MnCheckpointLane lane; + std::map headers; // height -> hash + std::map blocks; // height -> body + uint32_t tip{0}; + std::vector requested; + bool published{false}; + uint32_t published_as_of{0}; + std::vector> published_set; + // When set, request_block_fn delivers the block INLINE (the ordered-stream + // behaviour of a real peer answering getdata on one TCP connection). + bool auto_deliver{true}; + + BridgeHarness() + { + lane.set_tip_height_fn([this] { return tip; }); + lane.set_header_hash_at_fn( + [this](uint32_t h) -> std::optional { + auto it = headers.find(h); + if (it == headers.end()) return std::nullopt; + return it->second; + }); + lane.set_publish_fn( + [this](std::vector> set, uint32_t as_of) { + published = true; + published_as_of = as_of; + published_set = std::move(set); + }); + lane.set_request_block_fn([this](uint32_t h) { + requested.push_back(h); + if (!auto_deliver) return; + auto it = blocks.find(h); + if (it == blocks.end()) return; + lane.on_block_connected(it->second, h); + }); + } +}; + +MnCheckpoint good_checkpoint() +{ + auto cp = parse_mn_checkpoint(kRealCheckpoint1519543, "testnet"); + EXPECT_TRUE(cp.ok) << cp.error; + return cp; +} + +} // namespace + +// ═══════════════════════════════════════════════════════════════════════════ +// 1. The committed anchor parses, and says what it claims to say. +// ═══════════════════════════════════════════════════════════════════════════ +TEST(DashMnCheckpoint, RealCapturedAnchorParses) +{ + auto cp = parse_mn_checkpoint(kRealCheckpoint1519543, "testnet"); + ASSERT_TRUE(cp.ok) << cp.error; + EXPECT_FALSE(cp.unpinned); + EXPECT_EQ(cp.network, "testnet"); + EXPECT_EQ(cp.height, kAnchorHeight); + EXPECT_EQ(cp.blockhash.GetHex(), std::string(kAnchorHash)); + EXPECT_EQ(cp.entries.size(), 6u); + EXPECT_FALSE(cp.source.empty()) << "provenance must be recorded in-file"; + EXPECT_FALSE(cp.generated.empty()); + // Every masternode carries a payee -- the whole point of the anchor. + for (const auto& [h, mn] : cp.entries) + EXPECT_FALSE(mn.scriptPayout.m_data.empty()) << h.GetHex(); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 2. Checkpoint seed == RPC seed. The generator's field mapping is the same +// mapping mn_seed.hpp makes from the same dashd JSON, so an operator who +// switches between the two arms gets a byte-identical masternode set. +// ═══════════════════════════════════════════════════════════════════════════ +TEST(DashMnCheckpoint, CheckpointSetIsFieldIdenticalToRpcSeed) +{ + // The SAME six masternodes as dashd reported them, reduced to the fields + // the E2c parser consumes. (The full captured JSON lives in + // test_dash_mn_seed.cpp; restating the payee-relevant subset here keeps the + // two suites independent while still cross-checking the mapping.) + const char* kJson = R"([ + {"type":"Regular","proTxHash":"dc2e02ac95ce4ccc9843c38de7bdaf32f2a1d5966c054127a3f4ca4f4bbd5991", + "collateralHash":"4ee3ff5074723d995f4cb957a954587c6c637a42655ada8f4054037b28d1e7a8","collateralIndex":34, + "operatorReward":0,"state":{"version":1,"registeredHeight":838365,"lastPaidHeight":1519459, + "consecutivePayments":0,"PoSeRevivedHeight":1367840,"PoSeBanHeight":-1,"revocationReason":0, + "payoutAddress":"yVXDAM73Tg6A44Bm3qduXsMCYxzuqBCT48"}}, + {"type":"Evo","proTxHash":"9b653e767b978c10346d938c08dc8c5acd03c495f9d913e6fc652bfcae11a348", + "collateralHash":"75fe9d8d90619576ef11deb8550d023366bf9d85e686dc6d5afba0aca8827e21","collateralIndex":2, + "operatorReward":0,"state":{"version":2,"registeredHeight":1427623,"lastPaidHeight":1519460, + "consecutivePayments":0,"PoSeRevivedHeight":1446613,"PoSeBanHeight":-1,"revocationReason":0, + "payoutAddress":"yeRZBWYfeNE4yVUHV4ZLs83Ppn9aMRH57A"}}, + {"type":"Evo","proTxHash":"91bbce94c34ebde0d099c0a2cb7635c0c31425ebabcec644f4f1a0854bfa605d", + "collateralHash":"6ce8545e25d4f03aba1527062d9583ae01827c65b234bd979aca5954c6ae3a59","collateralIndex":30, + "operatorReward":0,"state":{"version":2,"registeredHeight":850334,"lastPaidHeight":1519461, + "consecutivePayments":0,"PoSeRevivedHeight":1368973,"PoSeBanHeight":-1,"revocationReason":0, + "payoutAddress":"yeRZBWYfeNE4yVUHV4ZLs83Ppn9aMRH57A"}}, + {"type":"Regular","proTxHash":"72ee70fa75262781a17d1eb69a6c3e97328208be98b59d5530164f31e481d3aa", + "collateralHash":"4ee3ff5074723d995f4cb957a954587c6c637a42655ada8f4054037b28d1e7a8","collateralIndex":96, + "operatorReward":0,"state":{"version":1,"registeredHeight":838365,"lastPaidHeight":1519462, + "consecutivePayments":0,"PoSeRevivedHeight":1367330,"PoSeBanHeight":-1,"revocationReason":0, + "payoutAddress":"yVXDAM73Tg6A44Bm3qduXsMCYxzuqBCT48"}}, + {"type":"Regular","proTxHash":"c87218fb9d031f4926c22430c69b4edf1f0fb80c331c1a79e3b1b3873407c0ac", + "collateralHash":"4ee3ff5074723d995f4cb957a954587c6c637a42655ada8f4054037b28d1e7a8","collateralIndex":62, + "operatorReward":0,"state":{"version":1,"registeredHeight":838365,"lastPaidHeight":1519463, + "consecutivePayments":0,"PoSeRevivedHeight":1367840,"PoSeBanHeight":-1,"revocationReason":0, + "payoutAddress":"yVXDAM73Tg6A44Bm3qduXsMCYxzuqBCT48"}}, + {"type":"Regular","proTxHash":"ecebeb952f56a61abaccd7bda7f4df5eccbd5f87a91bc4a8969535df1058158e", + "collateralHash":"f329c4c9d194159e81597e50144ce41a40e2b40860c7813a85eabb0454700a3d","collateralIndex":1, + "operatorReward":0,"state":{"version":2,"registeredHeight":1491043,"lastPaidHeight":1519464, + "consecutivePayments":0,"PoSeRevivedHeight":1507780,"PoSeBanHeight":-1,"revocationReason":0, + "payoutAddress":"yjTpMw9buZfv4jNkf87AHpDj95YSAFuDiX"}} + ])"; + + dash::coin::MnSeedStats st; + auto rpc_seed = dash::coin::parse_protx_list_seed( + nlohmann::json::parse(kJson), TESTNET_PUBKEY_VER, TESTNET_P2SH_VER, &st); + ASSERT_EQ(rpc_seed.size(), 6u); + + auto cp = good_checkpoint(); + ASSERT_EQ(cp.entries.size(), rpc_seed.size()); + + std::map from_ckpt(cp.entries.begin(), cp.entries.end()); + for (const auto& [h, rpc_mn] : rpc_seed) { + auto it = from_ckpt.find(h); + ASSERT_NE(it, from_ckpt.end()) + << "checkpoint is missing masternode " << h.GetHex(); + const MNState& ck = it->second; + EXPECT_EQ(ck.scriptPayout.m_data, rpc_mn.scriptPayout.m_data) + << "PAYEE MISMATCH for " << h.GetHex() + << " -- the checkpoint would mint a different coinbase than the" + " RPC seed"; + EXPECT_EQ(ck.nLastPaidHeight, rpc_mn.nLastPaidHeight); + EXPECT_EQ(ck.nRegisteredHeight, rpc_mn.nRegisteredHeight); + EXPECT_EQ(ck.nPoSeRevivedHeight, rpc_mn.nPoSeRevivedHeight); + EXPECT_EQ(ck.nPoSeBanHeight, rpc_mn.nPoSeBanHeight); + EXPECT_EQ(ck.nType, rpc_mn.nType); + EXPECT_EQ(ck.nVersion, rpc_mn.nVersion); + EXPECT_EQ(ck.isValid, rpc_mn.isValid); + EXPECT_EQ(ck.collateralOutpoint.hash, rpc_mn.collateralOutpoint.hash); + EXPECT_EQ(ck.collateralOutpoint.index, rpc_mn.collateralOutpoint.index); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 3. Fail-closed: every defect class refuses the WHOLE checkpoint. +// ═══════════════════════════════════════════════════════════════════════════ +TEST(DashMnCheckpoint, UnpinnedPayloadIsReportedDistinctly) +{ + // What a fresh checkout ships: a comment-only .inc. + auto cp = parse_mn_checkpoint("", "mainnet"); + EXPECT_FALSE(cp.ok); + EXPECT_TRUE(cp.unpinned) << "an absent anchor must not look like corruption"; + EXPECT_NE(cp.error.find("UNPINNED"), std::string::npos); + + auto cp2 = parse_mn_checkpoint("# nothing here\n\n# still nothing\n", "mainnet"); + EXPECT_FALSE(cp2.ok); + EXPECT_TRUE(cp2.unpinned); +} + +TEST(DashMnCheckpoint, DigestMismatchRefusesEverything) +{ + // Flip ONE masternode's lastPaidHeight without resealing: exactly the shape + // of a hand edit or a half-applied merge. + std::string tampered = mutate(kRealCheckpoint1519543, + "1519459", "1519999", /*reseal_it=*/false); + auto cp = parse_mn_checkpoint(tampered, "testnet"); + EXPECT_FALSE(cp.ok); + EXPECT_FALSE(cp.unpinned); + EXPECT_NE(cp.error.find("DIGEST MISMATCH"), std::string::npos) << cp.error; + EXPECT_TRUE(cp.entries.empty()) << "no partial set may survive a refusal"; +} + +TEST(DashMnCheckpoint, NetworkMismatchRefuses) +{ + auto cp = parse_mn_checkpoint(kRealCheckpoint1519543, "mainnet"); + EXPECT_FALSE(cp.ok); + EXPECT_NE(cp.error.find("network mismatch"), std::string::npos) << cp.error; +} + +TEST(DashMnCheckpoint, BadMagicRefuses) +{ + std::string p = mutate(kRealCheckpoint1519543, + "c2pool-dash-mn-checkpoint/1", + "c2pool-dash-mn-checkpoint/2"); + auto cp = parse_mn_checkpoint(p, "testnet"); + EXPECT_FALSE(cp.ok); + EXPECT_NE(cp.error.find("bad magic"), std::string::npos) << cp.error; +} + +TEST(DashMnCheckpoint, UnknownKeyRefusesRatherThanIgnoring) +{ + // A future format could add a load-bearing key; silently skipping it would + // load a set we do not actually understand. + std::string p = mutate(kRealCheckpoint1519543, + "count 6", "quorumhint 42\ncount 6"); + auto cp = parse_mn_checkpoint(p, "testnet"); + EXPECT_FALSE(cp.ok); + EXPECT_NE(cp.error.find("unknown key"), std::string::npos) << cp.error; +} + +TEST(DashMnCheckpoint, CountMismatchRefuses) +{ + std::string p = mutate(kRealCheckpoint1519543, "count 6", "count 5"); + auto cp = parse_mn_checkpoint(p, "testnet"); + EXPECT_FALSE(cp.ok); + EXPECT_NE(cp.error.find("count mismatch"), std::string::npos) << cp.error; +} + +TEST(DashMnCheckpoint, MissingPayoutScriptRefuses) +{ + std::string p = mutate( + kRealCheckpoint1519543, + "76a91464f2b2b84f62d68a2cd7f7f5fb2b5aa75ef716d788ac", "-"); + auto cp = parse_mn_checkpoint(p, "testnet"); + EXPECT_FALSE(cp.ok); + EXPECT_NE(cp.error.find("scriptPayout"), std::string::npos) << cp.error; +} + +TEST(DashMnCheckpoint, DuplicateProTxHashRefuses) +{ + // Duplicate the first mn line verbatim (and bump count so this test probes + // the duplicate guard, not the count guard). + std::string p(kRealCheckpoint1519543); + const size_t at = p.find("mn 72ee70fa"); + ASSERT_NE(at, std::string::npos); + const size_t eol = p.find('\n', at); + const std::string line = p.substr(at, eol - at + 1); + p.insert(eol + 1, line); + p = mutate(p, "count 6", "count 7"); + auto cp = parse_mn_checkpoint(p, "testnet"); + EXPECT_FALSE(cp.ok); + EXPECT_NE(cp.error.find("duplicate proTxHash"), std::string::npos) << cp.error; +} + +TEST(DashMnCheckpoint, NegativeAndGarbageNumbersRefuse) +{ + // strtoul would silently wrap "-1" to UINT32_MAX -- the exact sentinel bug + // class that froze the projected payee. And "12abc" would parse as 12. + for (const char* bad : {"-1", "12abc", "999999999999999999999", ""}) { + std::string p = mutate(kRealCheckpoint1519543, + " 838365 1519462 ", + std::string(" ") + bad + " 1519462 "); + auto cp = parse_mn_checkpoint(p, "testnet"); + EXPECT_FALSE(cp.ok) << "accepted registeredHeight '" << bad << "'"; + EXPECT_TRUE(cp.entries.empty()); + } +} + +TEST(DashMnCheckpoint, ZeroBlockhashRefuses) +{ + std::string p = mutate(kRealCheckpoint1519543, kAnchorHash, + std::string(64, '0')); + auto cp = parse_mn_checkpoint(p, "testnet"); + EXPECT_FALSE(cp.ok); + EXPECT_NE(cp.error.find("all-zero"), std::string::npos) << cp.error; +} + +TEST(DashMnCheckpoint, DigestRuleIgnoresCommentsAndBlankLines) +{ + // Comments must be free to add (the .inc header block is comments), so the + // digest domain must exclude them -- but must NOT exclude anything else. + std::string p = std::string("# a comment\n\n") + + kRealCheckpoint1519543 + "\n# trailing comment\n"; + auto cp = parse_mn_checkpoint(p, "testnet"); + EXPECT_TRUE(cp.ok) << cp.error; + EXPECT_EQ(cp.entries.size(), 6u); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 4. The bridge: real anchor + real blocks -> published set. +// ═══════════════════════════════════════════════════════════════════════════ +TEST(DashMnCheckpointBridge, ReplaysRealBlocksAndPublishesAtTip) +{ + BridgeHarness h; + h.headers[kAnchorHeight] = uint256S(kAnchorHash); + h.blocks[1519544] = block_from_hex(kBlockHex1519544); + h.blocks[1519545] = block_from_hex(kBlockHex1519545); + h.blocks[1519546] = block_from_hex(kBlockHex1519546); + h.tip = 1519546; + + h.lane.arm(good_checkpoint()); + EXPECT_EQ(h.lane.state(), MnCheckpointLane::State::Waiting); + + h.lane.pump(); + + ASSERT_TRUE(h.published) << "bridge status: " << h.lane.status(); + EXPECT_EQ(h.lane.state(), MnCheckpointLane::State::Published); + EXPECT_EQ(h.published_as_of, 1519546u) + << "the published snapshot must be labelled with the height it bridged" + " to, or the maintainer's apply cursor starts wrong"; + EXPECT_EQ(h.published_set.size(), 6u); + + // dashd's actual payment schedule over those three blocks (the same + // attribution the E2c suite pins against the RPC seed). + std::map out(h.published_set.begin(), h.published_set.end()); + EXPECT_EQ(out.at(uint256S("dc2e02ac95ce4ccc9843c38de7bdaf32f2a1d5966c05412" + "7a3f4ca4f4bbd5991")).nLastPaidHeight, 1519544u); + EXPECT_EQ(out.at(uint256S("9b653e767b978c10346d938c08dc8c5acd03c495f9d913e" + "6fc652bfcae11a348")).nLastPaidHeight, 1519545u); + EXPECT_EQ(out.at(uint256S("91bbce94c34ebde0d099c0a2cb7635c0c31425ebabcec64" + "4f4f1a0854bfa605d")).nLastPaidHeight, 1519546u); + + // The blocks were requested in ascending order from the cursor. + ASSERT_FALSE(h.requested.empty()); + EXPECT_EQ(h.requested.front(), 1519544u); + EXPECT_TRUE(std::is_sorted(h.requested.begin(), h.requested.end())); +} + +TEST(DashMnCheckpointBridge, IgnoresLiveTipBlocksArrivingMidBridge) +{ + // The cold-start race: the new_block inv leg delivers a block at the CURRENT + // tip while the bridge is still replaying history. Folding it would advance + // a stale cursor -- the E4-soak bad-cb-payee shape. + BridgeHarness h; + h.auto_deliver = false; + h.headers[kAnchorHeight] = uint256S(kAnchorHash); + h.tip = 1519546; + h.lane.arm(good_checkpoint()); + h.lane.pump(); + ASSERT_EQ(h.lane.state(), MnCheckpointLane::State::Bridging); + + // A far-future block arrives first. It must be ignored, NOT folded. + h.lane.on_block_connected(block_from_hex(kBlockHex1519546), 1519546); + EXPECT_EQ(h.lane.state(), MnCheckpointLane::State::Bridging) + << "an out-of-order live block must not fail or advance the bridge"; + EXPECT_EQ(h.lane.cursor_height(), kAnchorHeight); + EXPECT_FALSE(h.published); + + // Now the ordered replay proceeds normally. + h.lane.on_block_connected(block_from_hex(kBlockHex1519544), 1519544); + h.lane.on_block_connected(block_from_hex(kBlockHex1519545), 1519545); + h.lane.on_block_connected(block_from_hex(kBlockHex1519546), 1519546); + EXPECT_TRUE(h.published) << h.lane.status(); + EXPECT_EQ(h.published_as_of, 1519546u); +} + +TEST(DashMnCheckpointBridge, ChainPositionMismatchFailsClosed) +{ + // The anchor names a block our PoW-validated header chain does not hold at + // that height: wrong chain, wrong fork, or a fabricated anchor. + BridgeHarness h; + h.headers[kAnchorHeight] = uint256S( + "00000000000000000000000000000000000000000000000000000000deadbeef"); + h.tip = 1519546; + h.lane.arm(good_checkpoint()); + h.lane.pump(); + + EXPECT_EQ(h.lane.state(), MnCheckpointLane::State::FailedClosed); + EXPECT_NE(h.lane.status().find("CHAIN-POSITION MISMATCH"), std::string::npos) + << h.lane.status(); + EXPECT_FALSE(h.published); + // Terminal: further drives must not resurrect it. + h.headers[kAnchorHeight] = uint256S(kAnchorHash); + h.lane.pump(); + EXPECT_EQ(h.lane.state(), MnCheckpointLane::State::FailedClosed); + EXPECT_FALSE(h.published); +} + +TEST(DashMnCheckpointBridge, StaleAnchorBeyondBridgeBoundFailsClosed) +{ + BridgeHarness h; + h.headers[kAnchorHeight] = uint256S(kAnchorHash); + h.tip = kAnchorHeight + 5000; + h.lane.set_max_bridge_blocks(100); + h.lane.arm(good_checkpoint()); + h.lane.pump(); + + EXPECT_EQ(h.lane.state(), MnCheckpointLane::State::FailedClosed); + EXPECT_NE(h.lane.status().find("STALE"), std::string::npos) << h.lane.status(); + EXPECT_FALSE(h.published); + EXPECT_TRUE(h.requested.empty()) + << "a refused anchor must not start pulling block bodies"; +} + +TEST(DashMnCheckpointBridge, ReplayGapFailsClosed) +{ + // Blocks skipped mid-replay: dashd advanced its payment queue at heights we + // never folded, so ours is silently behind. Never publish. + BridgeHarness h; + h.auto_deliver = false; + h.headers[kAnchorHeight] = uint256S(kAnchorHash); + h.tip = 1519546; + h.lane.arm(good_checkpoint()); + h.lane.pump(); + ASSERT_EQ(h.lane.state(), MnCheckpointLane::State::Bridging); + + h.lane.on_block_connected(block_from_hex(kBlockHex1519544), 1519544); + EXPECT_EQ(h.lane.cursor_height(), 1519544u); + // 1519545 never arrives; the lane is waiting for it, so 1519546 is simply + // ignored and the bridge NEVER publishes. + h.lane.on_block_connected(block_from_hex(kBlockHex1519546), 1519546); + EXPECT_FALSE(h.published); + EXPECT_EQ(h.lane.state(), MnCheckpointLane::State::Bridging); + EXPECT_EQ(h.lane.cursor_height(), 1519544u); +} + +TEST(DashMnCheckpointBridge, PayeeDesyncOnWrongAnchorFailsClosed) +{ + // THE anchor falsification test. Corrupt the anchor so it projects the + // wrong payee, then replay a real block. The block's real coinbase does not + // pay what we project -> the anchor is proven wrong -> terminal refusal. + // This is what stops a bad trust anchor from ever reaching a template. + // + // 1519459 is dc2e02ac's lastPaidHeight, the LOWEST in the set, which is why + // dashd paid it at 1519544. Pushing it to the top of the queue makes some + // OTHER masternode our projected payee. + std::string p = mutate(kRealCheckpoint1519543, " 838365 1519459 ", + " 838365 1519543 "); + auto bad = parse_mn_checkpoint(p, "testnet"); + ASSERT_TRUE(bad.ok) << bad.error; // structurally valid, semantically wrong + + BridgeHarness h; + h.headers[kAnchorHeight] = uint256S(kAnchorHash); + h.blocks[1519544] = block_from_hex(kBlockHex1519544); + h.tip = 1519544; + h.lane.arm(bad); + h.lane.pump(); + + EXPECT_EQ(h.lane.state(), MnCheckpointLane::State::FailedClosed) + << h.lane.status(); + EXPECT_NE(h.lane.status().find("PAYEE DESYNC"), std::string::npos) + << h.lane.status(); + EXPECT_FALSE(h.published) + << "a falsified anchor must NEVER reach the maintainer"; +} + +TEST(DashMnCheckpointBridge, RefusedCheckpointArmsTerminallyFailedClosed) +{ + BridgeHarness h; + h.headers[kAnchorHeight] = uint256S(kAnchorHash); + h.tip = 1519546; + h.lane.arm(parse_mn_checkpoint("", "mainnet")); // UNPINNED + EXPECT_EQ(h.lane.state(), MnCheckpointLane::State::FailedClosed); + h.lane.pump(); + EXPECT_FALSE(h.published); + EXPECT_TRUE(h.requested.empty()); +} + +TEST(DashMnCheckpointBridge, WaitsQuietlyUntilHeadersReachTheAnchor) +{ + // Cold start: headers are still syncing. Not an error, and MUST NOT publish. + BridgeHarness h; + h.tip = 1000; + h.lane.arm(good_checkpoint()); + h.lane.pump(); + EXPECT_EQ(h.lane.state(), MnCheckpointLane::State::Waiting); + EXPECT_FALSE(h.published); + EXPECT_TRUE(h.requested.empty()); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 5. End-to-end through the REAL leg-4 event, plus the negative control. +// ═══════════════════════════════════════════════════════════════════════════ +namespace { + +// Mirrors main_dash's wiring: the lane publishes into Node::mn_list_update, +// leg 4 carries it to the maintainer, leg 2 supplies the tip. +struct E2eRig { + dash::interfaces::Node node; + dash::coin::NodeCoinState state; + dash::coin::CoinStateMaintainer maint{state}; + std::vector> subs; + BridgeHarness h; + + E2eRig() + { + // The anti-mint latch main_dash enables for the embedded arm. + maint.set_require_seeded_mn_set(true); + subs.push_back(c2pool::dash::wire_mn_list_ingest(node, maint)); + subs.push_back(c2pool::dash::wire_tip_ingest(node, maint)); + h.lane.set_publish_fn( + [this](std::vector> set, uint32_t as_of) { + dash::interfaces::MnListUpdate up; + up.mnstates = std::move(set); + up.as_of_height = as_of; + node.mn_list_update.happened(up); + }); + h.headers[kAnchorHeight] = uint256S(kAnchorHash); + h.blocks[1519544] = block_from_hex(kBlockHex1519544); + h.blocks[1519545] = block_from_hex(kBlockHex1519545); + h.blocks[1519546] = block_from_hex(kBlockHex1519546); + h.tip = 1519546; + } + + void fire_tip() + { + dash::interfaces::TipAdvance ta; + ta.prev_height = 1519546; + ta.prev_hash = uint256S(kAnchorHash); + ta.bits_for_next = 0x1d00ffff; + ta.mtp_at_tip = 1751000000; + ta.address_version = TESTNET_PUBKEY_VER; + ta.address_p2sh_version = TESTNET_P2SH_VER; + node.new_tip.happened(ta); + } +}; + +} // namespace + +TEST(DashMnCheckpointE2e, BridgePublishFlipsPopulatedThroughTheRealLeg4Event) +{ + E2eRig r; + EXPECT_FALSE(r.state.populated()) << "cold start must not be populated"; + + r.h.lane.arm(good_checkpoint()); + r.h.lane.pump(); + ASSERT_EQ(r.h.lane.state(), MnCheckpointLane::State::Published) + << r.h.lane.status(); + + r.fire_tip(); + + EXPECT_TRUE(r.state.populated()) + << "the bridged masternode set + a live tip must flip the embedded arm"; +} + +// NEGATIVE CONTROL. Identical wiring; the ONLY difference is a corrupted +// anchor. If this ever passes populated()==true, the fail-closed path is +// decorative. +TEST(DashMnCheckpointE2e, NegativeControlCorruptAnchorLeavesArmUnpopulated) +{ + E2eRig r; + std::string tampered = mutate(kRealCheckpoint1519543, + "1519459", "1519999", /*reseal_it=*/false); + auto bad = parse_mn_checkpoint(tampered, "testnet"); + ASSERT_FALSE(bad.ok); + + r.h.lane.arm(bad); + r.h.lane.pump(); + EXPECT_EQ(r.h.lane.state(), MnCheckpointLane::State::FailedClosed); + + r.fire_tip(); + + EXPECT_FALSE(r.state.populated()) + << "a refused anchor must leave the embedded arm unarmed -- templates" + " keep routing to the dashd fallback"; +} + +// Second negative control, one layer down: prove the anti-mint latch main_dash +// enables actually blocks the "one accidental ProRegTx arms the queue" path. +TEST(DashMnCheckpointE2e, NegativeControlAntiMintLatchBlocksUnseededArming) +{ + dash::coin::NodeCoinState state; + dash::coin::CoinStateMaintainer maint{state}; + maint.set_require_seeded_mn_set(true); + + // A non-empty masternode set with NO height stamp: what an unauthoritative + // source (or a block-connect registration) would produce. + auto cp = good_checkpoint(); + maint.on_mn_list_update(cp.entries, /*as_of_height=*/0); + + auto fire_tip = [&maint] { + maint.on_new_tip(/*prev_height=*/1519546, uint256S(kAnchorHash), + /*bits_for_next=*/0x1d00ffff, + /*mtp_at_tip=*/1751000000, + TESTNET_PUBKEY_VER, TESTNET_P2SH_VER); + }; + fire_tip(); + + EXPECT_FALSE(state.populated()) + << "an unheighted masternode set must never arm the embedded arm"; + + // Positive control on the same object: the SAME set WITH its height arms it. + maint.on_mn_list_update(cp.entries, kAnchorHeight); + fire_tip(); + EXPECT_TRUE(state.populated()) + << "a height-stamped authoritative snapshot must still arm normally"; +}