From b99b6651c4354b2c57c523efc2c24e3cad1f1174 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Wed, 29 Jul 2026 10:45:01 +0000 Subject: [PATCH] =?UTF-8?q?dash(tools):=20add=20gen=5Fmn=5Fcheckpoint.py?= =?UTF-8?q?=20=E2=80=94=20the=20missing=20daemonless=20MN-set=20anchor=20g?= =?UTF-8?q?enerator=20(#955)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E2d (#899) ships a release-pinned masternode-set checkpoint that the daemonless embedded arm cold-starts from, and FAILS CLOSED when it is unpinned/corrupt. But the generator its README, its .inc headers, and its runtime ERROR all name — tools/dash/gen_mn_checkpoint.py — did not exist, and neither did tools/dash/. So the fail-closed state was PERMANENT: no supported path could produce an anchor, and --embedded-mainnet could never build a template. This lands the remedy. pin fetch `protx list valid true ` from a dashd (RPC or an offline --protx-json capture), convert it to the checkpoint payload, and OVERWRITE the target .inc in place; print a provenance block for the release notes. Brackets the fetch with getblockcount and refetches if the tip moved (auto-height), verifies getblockchaininfo.chain matches --network, converts payoutAddress -> scriptPayout once, sorts by proTxHash for reproducible bytes, and re-parses what it wrote so it never ships a file the runtime would refuse. verify re-derive the digest and re-run every fail-closed rule the parser enforces (mn_checkpoint.hpp). Stdlib only (urllib/hashlib/json/base58check) — a release box needs nothing but python3. The field conversions MIRROR mn_seed.hpp::parse_protx_list_seed exactly (scriptPayout via base58check->P2PKH/P2SH, the dashd -1 "never" height clamps, Evo/Regular typing, operatorReward pct->fixed-point), so a checkpoint seed and an RPC seed produce byte-identical MNState — the coinbase-correctness contract. tools/dash/test_gen_mn_checkpoint.py proves this OFFLINE against the SAME committed testnet fixture (test/dash_mn_checkpoint_testnet_1519543.inc) that CheckpointSetIsFieldIdenticalToRpcSeed certifies equals the RPC seed: - the fixture re-derives its own digest eddc3386…; - pinning from the payee-relevant protx JSON subset reproduces the fixtures --- .gitignore | 9 +- tools/dash/gen_mn_checkpoint.py | 488 +++++++++++++++++++++++++++ tools/dash/test_gen_mn_checkpoint.py | 165 +++++++++ 3 files changed, 661 insertions(+), 1 deletion(-) create mode 100755 tools/dash/gen_mn_checkpoint.py create mode 100755 tools/dash/test_gen_mn_checkpoint.py diff --git a/.gitignore b/.gitignore index 33b33d211..37322dfd1 100644 --- a/.gitignore +++ b/.gitignore @@ -160,7 +160,14 @@ SHA256SUMS CLAUDE.md external-logs/ -tools/ +# tools/ holds build artifacts (ignored) EXCEPT the DASH masternode-set +# checkpoint generator, which is tracked release source: the checkpoints +# README + .inc headers instruct operators to run it at release time (#955). +tools/* +!tools/dash/ +tools/dash/* +!tools/dash/gen_mn_checkpoint.py +!tools/dash/test_gen_mn_checkpoint.py # CMake out-of-source build trees (any name starting with `build-`). # Catches build-spv/, build-qt/, build-relwithdebinfo/, etc. diff --git a/tools/dash/gen_mn_checkpoint.py b/tools/dash/gen_mn_checkpoint.py new file mode 100755 index 000000000..056fe5c60 --- /dev/null +++ b/tools/dash/gen_mn_checkpoint.py @@ -0,0 +1,488 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: AGPL-3.0-or-later +"""gen_mn_checkpoint.py -- pin / verify the DASH daemonless masternode-set checkpoint. + +The daemonless embedded arm cannot obtain the payout-bearing masternode set from +the P2P network (the Simplified MN List omits scriptPayout + nLastPaidHeight, and +neither is committed in merkleRootMNList). It cold-starts instead from a +release-pinned checkpoint compiled into the binary. This tool produces and +re-validates that checkpoint file. + + pin -- fetch `protx list valid true ` from a dashd (or an offline + capture), convert it to the checkpoint payload, and OVERWRITE the + target .inc in place. Prints a provenance block for the release notes. + verify -- re-derive the digest of an existing .inc and confirm every structural + rule the runtime parser enforces (mn_checkpoint.hpp). + +Authoritative format spec: src/impl/dash/coin/mn_checkpoint.hpp header comment and +src/impl/dash/coin/checkpoints/README.md. The field conversions here MIRROR +src/impl/dash/coin/mn_seed.hpp::parse_protx_list_seed so a checkpoint seed and an +RPC seed produce byte-identical MNState for the same masternode +(test/test_dash_mn_checkpoint.cpp::CheckpointSetIsFieldIdenticalToRpcSeed). + +Standard library only -- no third-party dependencies, so a release machine needs +nothing but python3. +""" + +import argparse +import base64 +import hashlib +import json +import os +import re +import sys +import urllib.request + +MAGIC = "c2pool-dash-mn-checkpoint/1" + +# Coin address version bytes (governance_object.hpp / chainparams): +# mainnet PUBKEY_ADDRESS=76 ('X'), SCRIPT_ADDRESS=16 ('7') +# testnet/devnet/regtest PUBKEY_ADDRESS=140 ('y'), SCRIPT_ADDRESS=19 ('8'/'9') +NETWORK_VERSIONS = { + "mainnet": (0x4C, 0x10), + "testnet": (0x8C, 0x13), + "devnet": (0x8C, 0x13), + "regtest": (0x8C, 0x13), +} +# dashd getblockchaininfo.chain -> our network label +CHAIN_TO_NETWORK = {"main": "mainnet", "test": "testnet", "devnet": "devnet", "regtest": "regtest"} + +# MNState C++ defaults we must reproduce when dashd omits a field (mn_state_db.hpp). +DEFAULT_NVERSION = 2 # vendor::ProTxVersion::BASIC_BLS +MnType_EVO_STRINGS = ("Evo", "HighPerformance") +BLS_PUBKEY_HEXLEN = 96 # BLS_PUBKEY_SIZE (48) * 2 + +_HEX_RE = re.compile(r"\A[0-9a-fA-F]+\Z") + + +def die(msg): + sys.stderr.write("gen_mn_checkpoint: " + msg + "\n") + sys.exit(1) + + +def is_hex_n(s, n): + return isinstance(s, str) and len(s) == n and bool(_HEX_RE.match(s)) + + +# ── base58check (stdlib only) ────────────────────────────────────────────── +_B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" +_B58_INDEX = {c: i for i, c in enumerate(_B58)} + + +def _b58decode(s): + num = 0 + for ch in s: + if ch not in _B58_INDEX: + raise ValueError("invalid base58 character %r" % ch) + num = num * 58 + _B58_INDEX[ch] + body = num.to_bytes((num.bit_length() + 7) // 8, "big") if num else b"" + n_pad = len(s) - len(s.lstrip("1")) + return b"\x00" * n_pad + body + + +def base58check_payload(addr): + """Decode a base58check string to its payload (version byte(s) + hash160).""" + raw = _b58decode(addr) + if len(raw) < 5: + raise ValueError("address too short") + body, checksum = raw[:-4], raw[-4:] + if hashlib.sha256(hashlib.sha256(body).digest()).digest()[:4] != checksum: + raise ValueError("bad base58check checksum") + return body + + +def address_to_script(addr, pubkey_ver, p2sh_ver): + """payoutAddress -> scriptPubKey bytes, byte-exact with mn_seed.hpp + hash160_to_merged_script. Only P2PKH/P2SH admitted (dashd consensus for + ProReg/ProUpReg payout scripts). Raises on any decode failure -- the caller + fail-closes the WHOLE seed, never emits a partial set.""" + body = base58check_payload(addr) + ver, h160 = body[0], body[1:] + if len(h160) != 20: + raise ValueError("address payload is not a 20-byte hash160") + if ver == pubkey_ver: # P2PKH: OP_DUP OP_HASH160 <20> OP_EQUALVERIFY OP_CHECKSIG + return b"\x76\xa9\x14" + h160 + b"\x88\xac" + if ver == p2sh_ver: # P2SH: OP_HASH160 <20> OP_EQUAL + return b"\xa9\x14" + h160 + b"\x87" + raise ValueError("address version byte %d is not valid for this network" % ver) + + +def address_to_keyid_hex(addr): + """owner/voting address -> CKeyID hash160 hex (base58check_to_hash160).""" + body = base58check_payload(addr) + h160 = body[1:] + if len(h160) != 20: + raise ValueError("address payload is not a 20-byte hash160") + return h160.hex() + + +# ── digest (mn_checkpoint.hpp::mn_checkpoint_digest) ─────────────────────── +def _rstrip(line): + return line.rstrip("\r \t") + + +def _in_digest_domain(line): + if line == "": + return False + if line[0] == "#": + return False + if line == "digest" or line.startswith("digest ") or line.startswith("digest\t"): + return False + return True + + +def mn_checkpoint_digest(payload): + """SHA-256 over every non-blank, non-'#'-comment, non-'digest' line, each + stripped of trailing whitespace and terminated by a single '\\n', in file + order. Deliberately trivial so the parser and this tool cannot disagree.""" + domain = [] + for raw in payload.split("\n"): + line = _rstrip(raw) + if _in_digest_domain(line): + domain.append(line + "\n") + return hashlib.sha256("".join(domain).encode("ascii")).hexdigest() + + +# ── protx entry -> mn record fields (mirrors mn_seed.hpp) ────────────────── +def _clamp_height(v): + """dashd -1 'never' sentinel and any negative clamp to 0 (sane_height_json).""" + return v if isinstance(v, int) and v > 0 else 0 + + +def _operator_reward_bp(v): + """JSON reports operatorReward as a percentage double; recover the internal + 0..10000 fixed-point (llround(pct * 100), half away from zero).""" + if not isinstance(v, (int, float)): + return 0 + x = float(v) * 100.0 + return int(x + 0.5) if x >= 0 else -int(-x + 0.5) + + +def build_mn_record(e, pubkey_ver, p2sh_ver): + if not isinstance(e, dict) or not is_hex_n(e.get("proTxHash"), 64): + raise ValueError("entry missing/invalid proTxHash") + s = e.get("state") + if not isinstance(s, dict): + raise ValueError("entry %s missing 'state' object" % e.get("proTxHash")) + + protx = e["proTxHash"].lower() + + coll = e.get("collateralHash") + coll = coll.lower() if is_hex_n(coll, 64) else "0" * 64 + coll_index = e["collateralIndex"] if isinstance(e.get("collateralIndex"), int) else 0 + + type_str = e.get("type", "Regular") + mn_type = 1 if type_str in MnType_EVO_STRINGS else 0 + + version = s["version"] if isinstance(s.get("version"), int) else DEFAULT_NVERSION + registered = _clamp_height(s.get("registeredHeight")) + last_paid = _clamp_height(s.get("lastPaidHeight")) + revived = _clamp_height(s.get("PoSeRevivedHeight")) + banned = _clamp_height(s.get("PoSeBanHeight")) + consecutive = _clamp_height(s.get("consecutivePayments")) + revocation = s["revocationReason"] if isinstance(s.get("revocationReason"), int) else 0 + operator_reward = _operator_reward_bp(e.get("operatorReward")) + + # THE payee keystone -- required, byte-exact, fail-closed on any bad address. + payout_addr = s.get("payoutAddress", "") + if not payout_addr: + raise ValueError("proTx %s has no payoutAddress" % protx) + script_payout = address_to_script(payout_addr, pubkey_ver, p2sh_ver).hex() + + op_addr = s.get("operatorPayoutAddress", "") + script_op = address_to_script(op_addr, pubkey_ver, p2sh_ver).hex() if op_addr else "-" + + owner = s.get("ownerAddress", "") + key_owner = address_to_keyid_hex(owner) if owner else "-" + voting = s.get("votingAddress", "") + key_voting = address_to_keyid_hex(voting) if voting else "-" + + pk = s.get("pubKeyOperator", "") + pubkey = pk.lower() if is_hex_n(pk, BLS_PUBKEY_HEXLEN) else "-" + + fields = [protx, coll, coll_index, mn_type, version, registered, last_paid, + revived, banned, consecutive, revocation, operator_reward, + script_payout, script_op, key_owner, key_voting, pubkey] + return protx, coll, coll_index, "mn " + " ".join(str(f) for f in fields) + + +def records_from_protx(protx_list, pubkey_ver, p2sh_ver): + """Convert a protx list into sorted mn record lines. Fail-closed: any + malformed entry, duplicate proTxHash, or duplicate collateral outpoint + aborts the WHOLE checkpoint (a partial set mints a wrong payee).""" + if not isinstance(protx_list, list): + die("protx list is not a JSON array") + seen_protx, seen_coll, records = set(), set(), [] + for i, e in enumerate(protx_list): + try: + protx, coll, idx, line = build_mn_record(e, pubkey_ver, p2sh_ver) + except ValueError as ex: + die("entry #%d: %s (seed aborted -- fail-closed)" % (i, ex)) + if protx in seen_protx: + die("duplicate proTxHash %s -- not a real DIP-3 set" % protx) + if (coll, idx) in seen_coll: + die("duplicate collateral outpoint %s:%d -- not a real DIP-3 set" % (coll, idx)) + seen_protx.add(protx) + seen_coll.add((coll, idx)) + records.append((protx, line)) + # Sort by proTxHash so re-pin diffs are readable and two pins of the same + # set produce identical bytes. + records.sort(key=lambda r: r[0]) + return [line for _, line in records] + + +# ── .inc read/write ──────────────────────────────────────────────────────── +_LITERAL_RE = re.compile(r'"((?:[^"\\]|\\.)*)"') + + +def unwrap_inc(text): + """Recover the raw payload the C++ compiler sees from an .inc file: the + concatenation of its adjacent string literals with escapes interpreted. + C++ `//` comment lines carry no payload and are dropped.""" + parts = [] + for line in text.split("\n"): + stripped = line.lstrip() + if stripped.startswith("//"): + continue + for m in _LITERAL_RE.finditer(line): + parts.append(m.group(1).encode("ascii").decode("unicode_escape")) + return "".join(parts) + + +def _c_literal(raw_line): + return '"' + raw_line.replace("\\", "\\\\").replace('"', '\\"') + '\\n"' + + +INC_HEADER = """// 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. +// +""" + + +def write_inc(path, header_lines, digest, mn_lines): + payload_lines = header_lines[:7] + ["digest " + digest] + mn_lines + with open(path, "w") as f: + f.write(INC_HEADER) + f.write("\n") + for line in payload_lines: + f.write(_c_literal(line) + "\n") + f.write('"\\n"\n') # trailing blank line in the payload + + +# ── minimal parser (mirrors mn_checkpoint.hpp fail-closed contract) ──────── +def parse_checkpoint(payload, expected_network=None): + """Return dict on success; raise ValueError on any defect (fail-closed).""" + if not any(_rstrip(l) and not _rstrip(l).startswith("#") for l in payload.split("\n")): + raise ValueError("UNPINNED: this build carries no masternode-set anchor") + + have = {} + entries, seen_protx, seen_coll = [], set(), set() + magic_seen = False + for n, raw in enumerate(payload.split("\n"), 1): + line = _rstrip(raw) + if line == "" or line[0] == "#": + continue + if not magic_seen: + if line != MAGIC: + raise ValueError("bad magic on line %d: %r" % (n, line)) + magic_seen = True + continue + parts = line.split(None, 1) + key = parts[0] + val = parts[1] if len(parts) > 1 else "" + if key in ("network", "height", "blockhash", "source", "generated", "count", "digest"): + if key in have and key != "source": + raise ValueError("duplicate '%s' line %d" % (key, n)) + have[key] = val + elif key == "mn": + f = val.split() + if len(f) != 17: + raise ValueError("mn line %d has %d fields, want 17" % (n, len(f))) + if not is_hex_n(f[0], 64): + raise ValueError("mn line %d: bad proTxHash" % n) + if f[12] in ("-", "") or not is_hex_n(f[12], len(f[12])) or len(f[12]) % 2: + raise ValueError("mn line %d: missing/undecodable scriptPayout" % n) + if f[0] in seen_protx: + raise ValueError("duplicate proTxHash on line %d" % n) + ck = (f[1], f[2]) + if ck in seen_coll: + raise ValueError("duplicate collateral outpoint on line %d" % n) + seen_protx.add(f[0]); seen_coll.add(ck) + entries.append(f) + else: + raise ValueError("unknown key '%s' on line %d" % (key, n)) + + for req in ("network", "height", "blockhash", "count", "digest"): + if req not in have: + raise ValueError("missing '%s' line" % req) + if not is_hex_n(have["blockhash"], 64) or int(have["blockhash"], 16) == 0: + raise ValueError("bad/zero blockhash") + if expected_network and have["network"] != expected_network: + raise ValueError("network mismatch: checkpoint is '%s', expected '%s'" + % (have["network"], expected_network)) + if int(have["count"]) != len(entries): + raise ValueError("count mismatch: header says %s, found %d" % (have["count"], len(entries))) + if not entries: + raise ValueError("checkpoint declares 0 masternodes (fail-closed)") + actual = mn_checkpoint_digest(payload) + if actual != have["digest"].lower(): + raise ValueError("DIGEST MISMATCH: declares %s, contents hash to %s" + % (have["digest"], actual)) + have["_entries"] = entries + return have + + +# ── RPC ──────────────────────────────────────────────────────────────────── +def rpc_call(url, user, password, method, params): + body = json.dumps({"jsonrpc": "1.0", "id": "gen", "method": method, "params": params}).encode() + req = urllib.request.Request(url, data=body) + if user is not None: + token = base64.b64encode(("%s:%s" % (user, password or "")).encode()).decode() + req.add_header("Authorization", "Basic " + token) + req.add_header("Content-Type", "application/json") + with urllib.request.urlopen(req, timeout=180) as resp: + out = json.load(resp) + if out.get("error"): + die("RPC %s error: %s" % (method, out["error"])) + return out["result"] + + +# ── commands ──────────────────────────────────────────────────────────────── +def cmd_pin(args): + network = args.network + if network not in NETWORK_VERSIONS: + die("unknown --network %r" % network) + pubkey_ver, p2sh_ver = NETWORK_VERSIONS[network] + + if args.protx_json: + if args.height is None or args.blockhash is None: + die("--protx-json (offline) requires --height and --blockhash") + with open(args.protx_json) as f: + protx_list = json.load(f) + height, blockhash = args.height, args.blockhash.lower() + source = args.source or ("offline capture (%s): protx list valid true %d" + % (os.path.basename(args.protx_json), height)) + else: + if not args.rpc_url: + die("pin needs either --protx-json or --rpc-url") + chain = rpc_call(args.rpc_url, args.rpc_user, args.rpc_password, "getblockchaininfo", [])["chain"] + got = CHAIN_TO_NETWORK.get(chain) + if got != network: + die("chain mismatch: dashd is on '%s' (%s) but --network is '%s'" % (chain, got, network)) + # Bracket protx with getblockcount; refetch if the tip moved and the + # height was auto-selected (a mislabelled height mis-seeds the cursor). + while True: + before = rpc_call(args.rpc_url, args.rpc_user, args.rpc_password, "getblockcount", []) + height = args.height if args.height is not None else before + protx_list = rpc_call(args.rpc_url, args.rpc_user, args.rpc_password, + "protx", ["list", "valid", True, height]) + after = rpc_call(args.rpc_url, args.rpc_user, args.rpc_password, "getblockcount", []) + if args.height is not None or after == before: + break + sys.stderr.write("gen_mn_checkpoint: tip moved %d->%d during fetch, refetching\n" + % (before, after)) + blockhash = rpc_call(args.rpc_url, args.rpc_user, args.rpc_password, "getblockhash", [height]).lower() + source = args.source or ("dashd %s RPC: protx list valid true %d" % (network, height)) + + if not is_hex_n(blockhash, 64): + die("blockhash %r is not 64 hex" % blockhash) + if not isinstance(height, int) or height <= 0: + die("height %r is not a positive integer" % height) + + mn_lines = records_from_protx(protx_list, pubkey_ver, p2sh_ver) + if not mn_lines: + die("protx list produced 0 masternodes -- refusing to write an empty anchor") + + generated = args.generated or _iso_now() + header = [MAGIC, "network " + network, "height %d" % height, "blockhash " + blockhash, + "source " + source, "generated " + generated, "count %d" % len(mn_lines)] + digest = mn_checkpoint_digest("\n".join(header + mn_lines) + "\n") + + out_path = args.output or _default_inc_path(network) + write_inc(out_path, header, digest, mn_lines) + + # Re-parse what we just wrote -- never ship a file the runtime would refuse. + with open(out_path) as f: + parse_checkpoint(unwrap_inc(f.read()), expected_network=network) + + if not args.quiet: + _print_provenance(out_path, network, height, blockhash, len(mn_lines), digest, generated) + + +def cmd_verify(args): + with open(args.file) as f: + payload = unwrap_inc(f.read()) + try: + cp = parse_checkpoint(payload, expected_network=args.network) + except ValueError as ex: + die("REFUSED: %s\n (%s)" % (ex, args.file)) + print("OK %s" % args.file) + print(" network=%s height=%s count=%d" % (cp["network"], cp["height"], len(cp["_entries"]))) + print(" blockhash=%s" % cp["blockhash"]) + print(" digest=%s (recomputed, matches)" % cp["digest"]) + + +def _default_inc_path(network): + here = os.path.dirname(os.path.abspath(__file__)) + return os.path.normpath(os.path.join( + here, "..", "..", "src", "impl", "dash", "coin", "checkpoints", + "dash_mn_checkpoint_%s.inc" % network)) + + +def _iso_now(): + import datetime + return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _print_provenance(path, network, height, blockhash, count, digest, generated): + bar = "=" * 74 + print(bar) + print("PROVENANCE -- paste into the release notes") + print(bar) + print(" file %s" % path) + print(" network %s" % network) + print(" height %d" % height) + print(" blockhash %s" % blockhash) + print(" count %d masternodes" % count) + print(" digest %s" % digest) + print(" generated %s" % generated) + print(bar) + print("Next: rebuild c2pool, start with --embedded-%s and NO coin-RPC, and" % network) + print("confirm the node LEAVES the CHECKPOINT REFUSED state and builds a template.") + + +def main(argv=None): + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = p.add_subparsers(dest="cmd", required=True) + + pp = sub.add_parser("pin", help="fetch protx set and overwrite the checkpoint .inc") + pp.add_argument("--network", required=True, choices=sorted(NETWORK_VERSIONS)) + pp.add_argument("--rpc-url") + pp.add_argument("--rpc-user") + pp.add_argument("--rpc-password") + pp.add_argument("--protx-json", help="offline: a captured `protx list valid true` JSON array") + pp.add_argument("--height", type=int, help="anchor height (required with --protx-json)") + pp.add_argument("--blockhash", help="anchor blockhash (required with --protx-json)") + pp.add_argument("--source", help="override the provenance 'source' line") + pp.add_argument("--generated", help="override the 'generated' timestamp (reproducible pins)") + pp.add_argument("--output", help="output .inc path (default: the in-tree checkpoint for --network)") + pp.add_argument("--quiet", action="store_true", help="suppress the provenance block (tests/CI)") + pp.set_defaults(func=cmd_pin) + + vp = sub.add_parser("verify", help="re-derive the digest and re-validate an .inc") + vp.add_argument("file") + vp.add_argument("--network", help="assert the checkpoint is for this network") + vp.set_defaults(func=cmd_verify) + + args = p.parse_args(argv) + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/tools/dash/test_gen_mn_checkpoint.py b/tools/dash/test_gen_mn_checkpoint.py new file mode 100755 index 000000000..05040bf43 --- /dev/null +++ b/tools/dash/test_gen_mn_checkpoint.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Offline KAT for tools/dash/gen_mn_checkpoint.py. + +Proves the generator is byte-correct against the SAME committed testnet fixture +(test/dash_mn_checkpoint_testnet_1519543.inc) that +test/test_dash_mn_checkpoint.cpp::CheckpointSetIsFieldIdenticalToRpcSeed certifies +is byte-identical to the RPC (mn_seed.hpp) seed. No network access. + + 1. verify -- the committed fixture re-derives its own digest (eddc3386...). + 2. parity -- pinning from the payee-relevant protx JSON subset reproduces the + fixture's payee-critical columns byte-for-byte (scriptPayout via + base58check->script, the -1 'never' height clamps, type/version/ + collateral). This is the coinbase-correctness contract. + 3. closed -- every defect class (digest tamper, count/network mismatch, + undecodable payee, unpinned) refuses the WHOLE checkpoint. + 4. roundtrip-- pin a synthetic set, verify accepts it, a one-byte edit refuses. + +Run from the repo root: python3 tools/dash/test_gen_mn_checkpoint.py +""" + +import json +import os +import sys +import tempfile + +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, _HERE) +import gen_mn_checkpoint as g # noqa: E402 + +REPO = os.path.normpath(os.path.join(_HERE, "..", "..")) +FIXTURE = os.path.join(REPO, "test", "dash_mn_checkpoint_testnet_1519543.inc") +FIXTURE_DIGEST = "eddc3386874eedd5f0e7619f198090390be13e9f5c9893512b629462a9953c91" +FIXTURE_HEIGHT = 1519543 +FIXTURE_BLOCKHASH = "00000048ba417fd364d4220de99d16d6e17651a565b9b529417905b99b3a541b" + +# The SAME six masternodes reduced to the payee-relevant fields the E2c parser +# consumes (test_dash_mn_checkpoint.cpp kJson). owner/voting/pubkey are the full +# capture's and are NOT payee-critical, so this subset omits them. +GOLDEN_JSON = json.loads(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"}} +]""") + +# payee-critical mn columns (0-based index into f = tokens after 'mn'): +# 0 proTxHash 1 collateralHash 2 index 3 type 4 version 5 registeredHeight +# 6 lastPaidHeight 7 poseRevivedHeight 8 poseBanHeight 9 consecutivePayments +# 10 revocationReason 11 operatorReward 12 scriptPayout +CRIT = list(range(0, 13)) + +_fails = [] + + +def check(name, cond): + print((" PASS " if cond else " FAIL ") + name) + if not cond: + _fails.append(name) + + +def mn_map(payload): + return {l.split()[1]: l.split()[1:] for l in payload.split("\n") if l.startswith("mn ")} + + +def refused(payload_text, network="testnet"): + try: + g.parse_checkpoint(g.unwrap_inc(payload_text), expected_network=network) + return False + except ValueError: + return True + + +def main(): + fixture_text = open(FIXTURE).read() + + print("[1] verify: committed fixture re-derives its own digest") + cp = g.parse_checkpoint(g.unwrap_inc(fixture_text), expected_network="testnet") + check("digest == %s" % FIXTURE_DIGEST[:16], cp["digest"] == FIXTURE_DIGEST) + check("height/count", cp["height"] == str(FIXTURE_HEIGHT) and len(cp["_entries"]) == 6) + + print("[2] parity: pin-from-JSON reproduces payee-critical columns byte-for-byte") + with tempfile.NamedTemporaryFile("w", suffix=".inc", delete=False) as tf: + out = tf.name + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as jf: + json.dump(GOLDEN_JSON, jf) + jsrc = jf.name + g.main(["pin", "--network", "testnet", "--protx-json", jsrc, + "--height", str(FIXTURE_HEIGHT), "--blockhash", FIXTURE_BLOCKHASH, + "--generated", "2026-07-26T13:23:51Z", "--source", "kat", "--output", out, "--quiet"]) + R, G = mn_map(g.unwrap_inc(fixture_text)), mn_map(g.unwrap_inc(open(out).read())) + ok = R.keys() == G.keys() + for h in R: + for c in CRIT: + if R[h][c] != G[h][c]: + ok = False + print(" MISMATCH %s col %d: %s != %s" % (h[:12], c, R[h][c], G[h][c])) + check("6 MN x 13 payee-critical columns identical", ok) + + print("[3] fail-closed: every defect class refuses the whole checkpoint") + sp = mn_map(g.unwrap_inc(fixture_text))[list(R)[0]][12] + check("scriptPayout hash160 tamper -> digest mismatch", + refused(fixture_text.replace(sp[6:46], "00" * 20))) + check("digest byte tamper", refused(fixture_text.replace("eddc3386", "eddc3387"))) + check("count mismatch", refused(fixture_text.replace("count 6", "count 5"))) + check("network mismatch", refused(fixture_text, network="mainnet")) + check("unpinned payload", refused('// comments only\n"\\n"\n')) + check("clean control accepted", not refused(fixture_text)) + + print("[4] round-trip: pin synthetic -> verify accepts -> one-byte edit refuses") + synth = [{"type": "Regular", + "proTxHash": "11" * 32, "collateralHash": "22" * 32, "collateralIndex": 0, + "operatorReward": 0, + "state": {"version": 2, "registeredHeight": 100, "lastPaidHeight": 200, + "PoSeRevivedHeight": -1, "PoSeBanHeight": -1, "revocationReason": 0, + "payoutAddress": "yVXDAM73Tg6A44Bm3qduXsMCYxzuqBCT48"}}] + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as jf: + json.dump(synth, jf) + sj = jf.name + with tempfile.NamedTemporaryFile("w", suffix=".inc", delete=False) as tf: + so = tf.name + g.main(["pin", "--network", "testnet", "--protx-json", sj, "--height", "12345", + "--blockhash", "ab" * 32, "--generated", "2026-01-01T00:00:00Z", "--output", so, "--quiet"]) + st = open(so).read() + check("synthetic pin verifies", not refused(st)) + check("flip one payload byte -> refused", refused(st.replace("height 12345", "height 12346"))) + + for p in (out, jsrc, sj, so): + os.unlink(p) + + print() + if _fails: + print("FAILED: " + ", ".join(_fails)) + sys.exit(1) + print("ALL PASS") + + +if __name__ == "__main__": + main()