From d80ed51225026de8335386ae633a9bcc879ebf23 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Fri, 19 Jun 2026 01:05:42 +0000 Subject: [PATCH 1/5] nmc(P1b): wire AuxPow::check_proof chain-merkle leg (step 1) + KATs check_proof() reconstructs the merged-mining merkle root from the aux block hash through chain_merkle_branch/chain_merkle_index via the P1a aux_merkle_root() primitive. New INCOMPLETE CheckResult state: a structurally-consistent chain-merkle walk returns INCOMPLETE (never VALID) because step 2 (MM-marker commitment + chain_id/slot binding), step 3 (parent-coinbase tx-merkle leg) and step 4 (parent PoW) are not built yet -- NMC still MUST NOT block-validate off this leaf. Malformed slot indices (negative, or wider than branch depth) return INVALID. 4 KATs pin the INCOMPLETE-vs-INVALID boundary so the leg cannot silently regress into asserting VALID. nmc_coin linked into the test (AuxPow ctor pulls MutableTransaction). 9/9 PASS. NMC-local, btc tree READ-ONLY. --- src/impl/nmc/coin/header_chain.hpp | 33 +++++++++++++--- src/impl/nmc/test/CMakeLists.txt | 4 ++ src/impl/nmc/test/auxpow_merkle_test.cpp | 50 ++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src/impl/nmc/coin/header_chain.hpp b/src/impl/nmc/coin/header_chain.hpp index 17b918076..1ec2bf8c6 100644 --- a/src/impl/nmc/coin/header_chain.hpp +++ b/src/impl/nmc/coin/header_chain.hpp @@ -193,6 +193,7 @@ struct AuxPow { /// Result of a (future) AuxPow verification. enum class CheckResult { NOT_IMPLEMENTED_P0, //!< P0 leaf — verification path not built yet + INCOMPLETE, //!< P1b — merkle leg(s) wired; steps 2/3/4 pending VALID, INVALID, }; @@ -200,7 +201,8 @@ struct AuxPow { /// Verify that this AuxPow actually proves work for `aux_block_hash` /// against the given expected aux chain id. /// - /// P0-DEFER: NOT IMPLEMENTED in P0. The real implementation must, against + /// P1b: step 1 (chain-merkle leg) is wired below via aux_merkle_root(). + /// The FULL implementation must, against /// a properly-pinned Namecoin chainparams: /// 1. recompute the merged-mining merkle root from `chain_merkle_branch` /// + `chain_merkle_index` starting at `aux_block_hash`, and confirm @@ -216,10 +218,31 @@ struct AuxPow { /// Until all four steps exist, NMC MUST NOT block-validate off this leaf. CheckResult check_proof(const uint256& aux_block_hash, int32_t expected_chain_id) const { - // P0-DEFER: structural stub. Deliberately performs NO verification. - (void)aux_block_hash; - (void)expected_chain_id; - return CheckResult::NOT_IMPLEMENTED_P0; + // P1b — chain-merkle leg (step 1). Walk the aux (NMC) block hash up + // through chain_merkle_branch/chain_merkle_index to reconstruct the + // merged-mining merkle root via aux_merkle_root(). The remaining steps + // are NOT built yet: step 2 (confirm that root is committed in the + // parent coinbase MM marker, with the chain_id/slot binding), step 3 + // (parent-coinbase tx-merkle leg — needs the witness-stripped BTC + // coinbase txid), step 4 (parent PoW vs target, consumed from the btc + // tree READ-ONLY). A structurally-consistent chain-merkle walk therefore + // returns INCOMPLETE, never VALID — NMC still MUST NOT block-validate + // off this leaf. + (void)expected_chain_id; // step-2 (chain_id/slot) binding lands later + + if (chain_merkle_index < 0) + return CheckResult::INVALID; // negative slot index is malformed + + try { + // Reconstructed MM root; its commitment check is step 2 (pending). + (void)aux_merkle_root( + aux_block_hash, chain_merkle_branch, + static_cast(chain_merkle_index)); + } catch (const std::invalid_argument&) { + return CheckResult::INVALID; // branch index out of range for depth + } + + return CheckResult::INCOMPLETE; } }; diff --git a/src/impl/nmc/test/CMakeLists.txt b/src/impl/nmc/test/CMakeLists.txt index 619f076dd..c00731fb1 100644 --- a/src/impl/nmc/test/CMakeLists.txt +++ b/src/impl/nmc/test/CMakeLists.txt @@ -19,6 +19,10 @@ if (BUILD_TESTING AND GTest_FOUND) # share/coin libs are needed -- this KAT only folds with core::Hash. target_link_libraries(nmc_auxpow_merkle_test PRIVATE c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage) + # P1b: the check_proof KATs construct AuxPow, whose parent_coinbase member + # is an nmc::coin::MutableTransaction with an out-of-line ctor -> link the + # nmc_coin lib (transaction.cpp). Still no node/pool/share libs (none exist). + target_link_libraries(nmc_auxpow_merkle_test PRIVATE nmc_coin) include(GoogleTest) include_directories(${gtest_SOURCE_DIR}/include ${gtest_SOURCE_DIR}) diff --git a/src/impl/nmc/test/auxpow_merkle_test.cpp b/src/impl/nmc/test/auxpow_merkle_test.cpp index 379223133..a1b0a0132 100644 --- a/src/impl/nmc/test/auxpow_merkle_test.cpp +++ b/src/impl/nmc/test/auxpow_merkle_test.cpp @@ -35,6 +35,7 @@ namespace { using nmc::coin::aux_merkle_root; +using nmc::coin::AuxPow; // Build a uint256 whose first byte is `b` (rest zero) — distinct, legible leaves. static uint256 leaf_of(unsigned char b) @@ -100,4 +101,53 @@ TEST(NmcAuxMerkle, IndexTooLargeForBranchThrows) EXPECT_THROW(aux_merkle_root(leaf, {sib}, 2u), std::invalid_argument); } + +// --------------------------------------------------------------------------- +// P1b: AuxPow::check_proof chain-merkle leg (step 1) wiring. +// +// check_proof() now reconstructs the merged-mining merkle root from the aux +// block hash through chain_merkle_branch/index via aux_merkle_root(). Steps 2 +// (MM-marker commitment), 3 (parent-coinbase tx-merkle leg) and 4 (parent PoW) +// are NOT built, so a structurally-consistent walk returns INCOMPLETE — never +// VALID. A malformed slot index (negative, or wider than the branch depth) +// returns INVALID. These KATs pin exactly that boundary so the leg can never +// silently regress into asserting VALID before the rest of the proof exists. +// --------------------------------------------------------------------------- + +TEST(NmcAuxCheckProof, ChainLegStructurallyValidIsIncomplete) +{ + AuxPow ap; + ap.chain_merkle_branch = {leaf_of(0xbe)}; + ap.chain_merkle_index = 0; + EXPECT_EQ(ap.check_proof(leaf_of(0x01), /*expected_chain_id=*/1), + AuxPow::CheckResult::INCOMPLETE); +} + +TEST(NmcAuxCheckProof, EmptyChainBranchIsIncompleteNeverValid) +{ + AuxPow ap; // empty branch, index 0 => identity walk, still not provable + EXPECT_EQ(ap.check_proof(leaf_of(0x07), 1), + AuxPow::CheckResult::INCOMPLETE); + EXPECT_NE(ap.check_proof(leaf_of(0x07), 1), + AuxPow::CheckResult::VALID); +} + +TEST(NmcAuxCheckProof, NegativeChainIndexIsInvalid) +{ + AuxPow ap; + ap.chain_merkle_branch = {leaf_of(0x0a)}; + ap.chain_merkle_index = -1; + EXPECT_EQ(ap.check_proof(leaf_of(0x02), 1), + AuxPow::CheckResult::INVALID); +} + +TEST(NmcAuxCheckProof, ChainIndexTooWideForDepthIsInvalid) +{ + AuxPow ap; + ap.chain_merkle_branch = {leaf_of(0x0a)}; // depth 1 => max index 1 + ap.chain_merkle_index = 2; + EXPECT_EQ(ap.check_proof(leaf_of(0x02), 1), + AuxPow::CheckResult::INVALID); +} + } // namespace From 89b321e1f92eda7d340e1b6c7c4d824d5db61280 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Fri, 19 Jun 2026 01:28:23 +0000 Subject: [PATCH 2/5] nmc(P1c): wire AuxPow::check_proof parent-coinbase tx-merkle leg (step 3) + witness-stripped txid KAT Adds nmc::coin::parent_coinbase_txid() (Hash over TX_NO_WITNESS serialization, byte-identical to btc compute_txid() but NMC-local per the coin fence) and wires AuxPow::check_proof step 3: reconstruct the parent block tx-merkle-root from the witness-stripped coinbase txid through parent_coinbase_branch/index via aux_merkle_root(). When a parent header is present the reconstructed root MUST equal parent_header.m_merkle_root, else INVALID; malformed legs (negative index, index wider than branch depth) are INVALID. Steps 2 (MM-marker commitment) and 4 (parent PoW) remain unbuilt, so a structurally-consistent proof still returns INCOMPLETE, never VALID -- NMC MUST NOT block-validate off this leaf. The load-bearing KAT asserts the exact byte serialization: the txid is hashed over the LEGACY (no-witness) layout -- txid, NOT wtxid -- re-derived field-by- field without SerializeTransaction's witness/marker path, and is shown to differ from the wtxid for a coinbase carrying the BIP141 reserved value. Plus 4 check_proof boundary KATs (match=>INCOMPLETE, mismatch/negative/too-wide=> INVALID). nmc_auxpow_merkle_test 14/14 PASS exit 0. NMC-local, btc tree READ-ONLY, test target name unchanged (CI allowlist untouched). --- src/impl/nmc/coin/header_chain.hpp | 62 +++++++++--- src/impl/nmc/test/auxpow_merkle_test.cpp | 121 +++++++++++++++++++++++ 2 files changed, 172 insertions(+), 11 deletions(-) diff --git a/src/impl/nmc/coin/header_chain.hpp b/src/impl/nmc/coin/header_chain.hpp index 1ec2bf8c6..f9a9a5b3e 100644 --- a/src/impl/nmc/coin/header_chain.hpp +++ b/src/impl/nmc/coin/header_chain.hpp @@ -117,6 +117,23 @@ inline uint256 aux_merkle_root(const uint256& leaf, return cur; } +// ─── Parent-coinbase txid (P1c: AuxPow step-3 leaf identity) ─────── + +/// Witness-stripped transaction id of the parent (BTC) coinbase. +/// +/// AuxPow step 3 proves the parent coinbase is committed in the parent +/// block's transaction merkle root. That tree commits *txids*, NOT wtxids — +/// so the coinbase id MUST be hashed over the LEGACY (no-witness, no segwit +/// marker/flag) serialization even though a real BTC coinbase carries a +/// witness (the BIP141 segwit-commitment reserved value). Byte-for-byte +/// identical to the btc tree's compute_txid() (mempool.hpp) but kept +/// NMC-LOCAL per the coin fence: consumes only the nmc::coin transaction +/// serializer (TX_NO_WITNESS) + core::Hash, no btc include. +inline uint256 parent_coinbase_txid(const MutableTransaction& tx) { + auto packed = pack(TX_NO_WITNESS(tx)); + return Hash(packed.get_span()); +} + // ─── AuxPow (merge-mining proof) ───────────────────────────────────────────── /// One aux-chain slot inside a parent coinbase's merged-mining merkle tree. @@ -218,21 +235,25 @@ struct AuxPow { /// Until all four steps exist, NMC MUST NOT block-validate off this leaf. CheckResult check_proof(const uint256& aux_block_hash, int32_t expected_chain_id) const { - // P1b — chain-merkle leg (step 1). Walk the aux (NMC) block hash up - // through chain_merkle_branch/chain_merkle_index to reconstruct the - // merged-mining merkle root via aux_merkle_root(). The remaining steps - // are NOT built yet: step 2 (confirm that root is committed in the - // parent coinbase MM marker, with the chain_id/slot binding), step 3 - // (parent-coinbase tx-merkle leg — needs the witness-stripped BTC - // coinbase txid), step 4 (parent PoW vs target, consumed from the btc - // tree READ-ONLY). A structurally-consistent chain-merkle walk therefore - // returns INCOMPLETE, never VALID — NMC still MUST NOT block-validate - // off this leaf. + // P1b+P1c — two merkle legs wired. Step 1 (chain-merkle) walks the + // aux (NMC) block hash through chain_merkle_branch/index to reconstruct + // the merged-mining root; step 3 (parent-coinbase tx-merkle) walks the + // witness-stripped parent coinbase txid through + // parent_coinbase_branch/index and, when a parent header is present, + // requires it to reproduce that header's tx-merkle-root. The remaining + // steps are NOT built yet: step 2 (confirm the reconstructed MM root is + // committed in the parent coinbase's MM marker, with the chain_id/slot + // binding) and step 4 (parent PoW vs target, consumed from the btc tree + // READ-ONLY). Because those gates do not exist yet, a structurally- + // consistent proof returns INCOMPLETE, never VALID — NMC still MUST NOT + // block-validate off this leaf. Any malformed leg (negative slot index, + // an index wider than its branch depth, or a parent-coinbase leg that + // does not reconstruct the parent header's tx-merkle-root) is INVALID. (void)expected_chain_id; // step-2 (chain_id/slot) binding lands later + // ── step 1 (P1b): chain-merkle leg ── if (chain_merkle_index < 0) return CheckResult::INVALID; // negative slot index is malformed - try { // Reconstructed MM root; its commitment check is step 2 (pending). (void)aux_merkle_root( @@ -242,6 +263,25 @@ struct AuxPow { return CheckResult::INVALID; // branch index out of range for depth } + // ── step 3 (P1c): parent-coinbase tx-merkle leg ── + if (parent_coinbase_index < 0) + return CheckResult::INVALID; // negative slot index is malformed + uint256 reconstructed_parent_merkle; + try { + // The parent tx-merkle tree commits the WITNESS-STRIPPED txid. + reconstructed_parent_merkle = aux_merkle_root( + parent_coinbase_txid(parent_coinbase), parent_coinbase_branch, + static_cast(parent_coinbase_index)); + } catch (const std::invalid_argument&) { + return CheckResult::INVALID; // branch index out of range for depth + } + // The equality gate only fires once a parent header is present: a real + // proof always carries one, but a leg-only structural fixture leaves it + // null and has nothing to match against yet (steps 2/4 still pending). + if (!parent_header.IsNull() && + reconstructed_parent_merkle != parent_header.m_merkle_root) + return CheckResult::INVALID; + return CheckResult::INCOMPLETE; } }; diff --git a/src/impl/nmc/test/auxpow_merkle_test.cpp b/src/impl/nmc/test/auxpow_merkle_test.cpp index a1b0a0132..c53a88267 100644 --- a/src/impl/nmc/test/auxpow_merkle_test.cpp +++ b/src/impl/nmc/test/auxpow_merkle_test.cpp @@ -150,4 +150,125 @@ TEST(NmcAuxCheckProof, ChainIndexTooWideForDepthIsInvalid) AuxPow::CheckResult::INVALID); } +// --------------------------------------------------------------------------- +// P1c: AuxPow::check_proof parent-coinbase tx-merkle leg (step 3) wiring. +// +// step 3 reconstructs the parent block's transaction merkle root from the +// WITNESS-STRIPPED parent coinbase txid through parent_coinbase_branch/index +// via aux_merkle_root(), and (once a parent header is present) requires it to +// equal parent_header.m_merkle_root. The load-bearing test asserts the exact +// byte serialization of the txid: it is hashed over the LEGACY (no-witness) +// layout -- txid, NOT wtxid -- so a coinbase carrying the BIP141 segwit +// reserved value still hashes witness-stripped. The legacy preimage is +// re-derived here field-by-field WITHOUT calling SerializeTransaction's +// witness/marker path, so a stray marker/flag byte is caught, not mirrored. +// Per-coin isolation: src/impl/nmc/ only; btc tree consumed READ-ONLY. +// --------------------------------------------------------------------------- + +using nmc::coin::MutableTransaction; +using nmc::coin::TxIn; +using nmc::coin::TxOut; +using nmc::coin::parent_coinbase_txid; + +// A minimal parent (BTC) coinbase carrying the BIP141 witness reserved value +// (single 32-byte zero stack item) -- exactly what a real segwit coinbase +// carries, so the txid path is forced to strip a present witness. +static MutableTransaction make_parent_coinbase() +{ + MutableTransaction tx; + tx.version = 1; + tx.locktime = 0; + TxIn in; + in.prevout.hash.SetNull(); + in.prevout.index = 0xffffffffu; + static const unsigned char sig[] = {0x03, 0x4e, 0x4d, 0x43}; // arbitrary scriptSig + in.scriptSig = OPScript(sig, sig + sizeof(sig)); + in.sequence = 0xffffffffu; + tx.vin.push_back(in); + TxOut out; + out.value = 5000000000LL; + tx.vout.push_back(out); + tx.vin[0].scriptWitness.stack.assign(1, std::vector(32, 0x00)); + return tx; +} + +// Independent legacy (no-witness) txid: lay out version|vin|vout|locktime via +// PackStream directly (the TxIn serializer writes prevout+scriptSig+sequence, +// NO witness) and double-SHA256 it -- never touching parent_coinbase_txid's +// SerializeTransaction path. +static uint256 legacy_txid(const MutableTransaction& tx) +{ + PackStream ref; + ref << tx.version; + ref << tx.vin; + ref << tx.vout; + ref << tx.locktime; + auto sp = std::span( + reinterpret_cast(ref.data()), ref.size()); + return Hash(sp); +} + +TEST(NmcAuxParentCoinbase, TxidIsWitnessStrippedLegacyHashNotWtxid) +{ + auto cb = make_parent_coinbase(); + ASSERT_TRUE(cb.HasWitness()); // a real BTC coinbase carries the reserved value + + // (a) txid is hashed over the independently-laid-out legacy serialization. + EXPECT_EQ(parent_coinbase_txid(cb), legacy_txid(cb)); + + // (b) txid != wtxid: the with-witness hash differs precisely because the + // witness is present and the txid path must strip it. + auto wpacked = pack(nmc::coin::TX_WITH_WITNESS(cb)); + uint256 wtxid = Hash(wpacked.get_span()); + EXPECT_NE(parent_coinbase_txid(cb), wtxid); +} + +TEST(NmcAuxCheckProof, ParentLegReconstructingHeaderMerkleIsIncomplete) +{ + AuxPow ap; + ap.parent_coinbase = make_parent_coinbase(); + uint256 cbid = parent_coinbase_txid(ap.parent_coinbase); + uint256 sib = leaf_of(0x55); + ap.parent_coinbase_branch = {sib}; + ap.parent_coinbase_index = 0; // bit0=0 => coinbase LEFT + // parent header commits the reconstructed tx-merkle-root; mark it non-null. + ap.parent_header.m_merkle_root = combine(cbid, sib); + ap.parent_header.m_bits = 1; // non-null so the equality gate fires + // chain leg left at defaults (empty branch, index 0) => passes step 1. + EXPECT_EQ(ap.check_proof(leaf_of(0x01), 1), + AuxPow::CheckResult::INCOMPLETE); +} + +TEST(NmcAuxCheckProof, ParentLegNotReconstructingHeaderMerkleIsInvalid) +{ + AuxPow ap; + ap.parent_coinbase = make_parent_coinbase(); + ap.parent_coinbase_branch = {leaf_of(0x55)}; + ap.parent_coinbase_index = 0; + ap.parent_header.m_merkle_root = leaf_of(0x99); // deliberately wrong root + ap.parent_header.m_bits = 1; // non-null => equality gate fires + EXPECT_EQ(ap.check_proof(leaf_of(0x01), 1), + AuxPow::CheckResult::INVALID); +} + +TEST(NmcAuxCheckProof, NegativeParentCoinbaseIndexIsInvalid) +{ + AuxPow ap; + ap.parent_coinbase = make_parent_coinbase(); + ap.parent_coinbase_branch = {leaf_of(0x55)}; + ap.parent_coinbase_index = -1; + EXPECT_EQ(ap.check_proof(leaf_of(0x01), 1), + AuxPow::CheckResult::INVALID); +} + +TEST(NmcAuxCheckProof, ParentCoinbaseIndexTooWideForDepthIsInvalid) +{ + AuxPow ap; + ap.parent_coinbase = make_parent_coinbase(); + ap.parent_coinbase_branch = {leaf_of(0x55)}; // depth 1 => max index 1 + ap.parent_coinbase_index = 2; + EXPECT_EQ(ap.check_proof(leaf_of(0x01), 1), + AuxPow::CheckResult::INVALID); +} + } // namespace From 3c25d1187fc044a5d70c8dcbca191890a61b2d4c Mon Sep 17 00:00:00 2001 From: frstrtr Date: Fri, 19 Jun 2026 01:55:27 +0000 Subject: [PATCH 3/5] nmc(p1c-step2): wire AuxPow::check_proof MM-marker commitment + chain_id/slot binding Step 2 of AuxPow verification: confirm the chain-merkle root reconstructed in step 1 is the one committed inside the parent (BTC) coinbase merged-mining marker, and that this aux chain occupies the deterministic slot its (nonce, chain_id) demands. NMC-local port of Namecoin/Bitcoin auxpow.cpp CAuxPow::check coinbase scan (coin fence: only std + core/* types, no btc include): * scan_mm_commitment(): the 4-byte pchMergedMiningHeader (fa be 6d 6d) must appear exactly once; the chain-merkle root must follow it immediately in reversed (big-endian display) byte order; the next LE uint32 tree size must equal 2^height; the following LE uint32 nonce must place the chain at chain_index == aux_expected_index(nonce, chain_id, height). * aux_expected_index(): byte-faithful CAuxPow::getExpectedIndex LCG. Staged posture preserved: a coinbase carrying no marker (leg-only fixture or empty parent coinbase) returns ABSENT -> step 2 skipped, mirroring the null-parent-header gate in step 3. A marker that commits a different root, wrong tree size, wrong slot, or is duplicated -> INVALID. The proof still never reaches VALID -- step 4 (parent PoW vs target, btc tree READ-ONLY) is unbuilt, so a structurally-consistent proof stays INCOMPLETE and NMC MUST NOT block-validate off this leaf. KATs: aux_expected_index pinned to offline-computed LCG values; the committed root laid out by hand (magic|reversed-root|LE size|LE nonce) without calling scan_mm_commitment, so a parse/scan bug is caught not mirrored. nmc_auxpow_merkle_test 22/22 PASS exit 0 (was 14/14, +8). --- src/impl/nmc/coin/header_chain.hpp | 149 +++++++++++++++++-- src/impl/nmc/test/auxpow_merkle_test.cpp | 175 +++++++++++++++++++++++ 2 files changed, 310 insertions(+), 14 deletions(-) diff --git a/src/impl/nmc/coin/header_chain.hpp b/src/impl/nmc/coin/header_chain.hpp index f9a9a5b3e..5d4badbd8 100644 --- a/src/impl/nmc/coin/header_chain.hpp +++ b/src/impl/nmc/coin/header_chain.hpp @@ -41,6 +41,7 @@ #include #include +#include #include #include #include @@ -134,6 +135,105 @@ inline uint256 parent_coinbase_txid(const MutableTransaction& tx) { return Hash(packed.get_span()); } +// ─── Merged-mining commitment scan (P1c-step2: AuxPow step-2 binding) ── + +/// pchMergedMiningHeader — the 4-byte magic tag that precedes the merged-mining +/// commitment inside the parent coinbase scriptSig. Byte-faithful with +/// Namecoin/Bitcoin auxpow.cpp (the same SSOT btc/doge consume); kept NMC-LOCAL +/// per the coin fence (only std + core types, no btc include). +inline constexpr unsigned char MM_HEADER_MAGIC[4] = {0xfa, 0xbe, 'm', 'm'}; + +/// Read a little-endian uint32 from 4 bytes WITHOUT a host-endian memcpy: the +/// on-wire MM size/nonce fields are always little-endian. +inline uint32_t read_le32(const unsigned char* p) { + return static_cast(p[0]) + | (static_cast(p[1]) << 8) + | (static_cast(p[2]) << 16) + | (static_cast(p[3]) << 24); +} + +/// Deterministic chain-merkle slot for a (nonce, chain_id, height) triple. +/// Byte-faithful port of Namecoin/Bitcoin CAuxPow::getExpectedIndex: pins each +/// aux chain to a pseudo-random-but-fixed slot so the same parent work cannot be +/// replayed for two chains and inter-chain slot clashes are minimised. The +/// arithmetic is intentionally wrap-around uint32 (LCG constants 1103515245 / +/// 12345) to match the reference bit-for-bit. +inline uint32_t aux_expected_index(uint32_t nonce, int32_t chain_id, unsigned height) { + uint32_t rnd = nonce; + rnd = rnd * 1103515245u + 12345u; + rnd += static_cast(chain_id); + rnd = rnd * 1103515245u + 12345u; + return rnd % (1u << height); +} + +/// Result of scanning a parent coinbase scriptSig for the merged-mining +/// commitment of a given chain-merkle root. +enum class MMScan { + ABSENT, //!< no MM magic in the scriptSig — nothing to assert (staged leg) + MATCH, //!< magic found and root/size/nonce/slot all bind correctly + MISMATCH, //!< magic found but the commitment is malformed or inconsistent +}; + +/// Step 2 of AuxPow verification: confirm the reconstructed chain-merkle root is +/// the one committed inside the parent coinbase's merged-mining marker, and that +/// this chain occupies the slot the (nonce, chain_id) binding demands. +/// +/// Byte-faithful port of the coinbase scan in Namecoin/Bitcoin auxpow.cpp +/// (CAuxPow::check), kept NMC-LOCAL per the coin fence (only std + core types): +/// * the 4-byte MM magic must appear exactly once; +/// * the chain-merkle root must follow the magic immediately, stored in the +/// REVERSED (big-endian display) byte order auxpow.cpp uses; +/// * the next 4 LE bytes are the tree size and MUST equal 2^height; +/// * the following 4 LE bytes are the nonce, and chain_index MUST equal +/// aux_expected_index(nonce, chain_id, height). +/// Returns ABSENT when the magic is absent entirely — the staged-leg posture +/// (mirrors the null-parent-header gate in step 3): step 2 cannot be asserted off +/// a fixture carrying no marker, and the proof never upgrades to VALID regardless. +/// NOTE (constant-pinning): the legacy no-magic "root in the first 20 bytes" +/// back-compat branch of the reference is intentionally NOT honoured — post- +/// activation Namecoin merge-mining always carries the magic; the final +/// endianness/layout cross-check happens against a live namecoind at pinning. +/// chain_merkle_root is taken by value (base_uint::begin() is non-const). +inline MMScan scan_mm_commitment(const std::vector& script, + uint256 chain_merkle_root, + unsigned chain_height, + int32_t expected_chain_id, + uint32_t chain_index) { + const unsigned char* magic = MM_HEADER_MAGIC; + auto magic_it = std::search(script.begin(), script.end(), magic, magic + 4); + if (magic_it == script.end()) + return MMScan::ABSENT; // staged: no commitment to bind against yet + + // Replay guard: exactly one MM header may appear (the reference rejects a + // second so one parent block cannot smuggle two commitments for one chain). + if (std::search(magic_it + 4, script.end(), magic, magic + 4) != script.end()) + return MMScan::MISMATCH; + + // The chain-merkle root is committed reversed (big-endian display order). + const unsigned char* rb = + reinterpret_cast(chain_merkle_root.begin()); + std::vector root_be(rb, rb + uint256::BYTES); + std::reverse(root_be.begin(), root_be.end()); + + auto root_pos = magic_it + 4; + if (static_cast(script.end() - root_pos) < root_be.size()) + return MMScan::MISMATCH; // truncated before the root + if (!std::equal(root_be.begin(), root_be.end(), root_pos)) + return MMScan::MISMATCH; // magic present but commits a different root + + auto pc = root_pos + static_cast(root_be.size()); + if (script.end() - pc < 8) + return MMScan::MISMATCH; // missing the 4-byte size + 4-byte nonce + uint32_t n_size = read_le32(&*pc); + uint32_t n_nonce = read_le32(&*(pc + 4)); + if (n_size != (1u << chain_height)) + return MMScan::MISMATCH; // tree size disagrees with the branch depth + if (chain_index != aux_expected_index(n_nonce, expected_chain_id, chain_height)) + return MMScan::MISMATCH; // chain sits in the wrong deterministic slot + + return MMScan::MATCH; +} + // ─── AuxPow (merge-mining proof) ───────────────────────────────────────────── /// One aux-chain slot inside a parent coinbase's merged-mining merkle tree. @@ -235,34 +335,55 @@ struct AuxPow { /// Until all four steps exist, NMC MUST NOT block-validate off this leaf. CheckResult check_proof(const uint256& aux_block_hash, int32_t expected_chain_id) const { - // P1b+P1c — two merkle legs wired. Step 1 (chain-merkle) walks the + // P1b+P1c — three merkle legs wired. Step 1 (chain-merkle) walks the // aux (NMC) block hash through chain_merkle_branch/index to reconstruct - // the merged-mining root; step 3 (parent-coinbase tx-merkle) walks the - // witness-stripped parent coinbase txid through + // the merged-mining root; step 2 (MM-marker) confirms that root is the + // one committed inside the parent coinbase's merged-mining marker, with + // the (nonce, chain_id) slot binding; step 3 (parent-coinbase tx-merkle) + // walks the witness-stripped parent coinbase txid through // parent_coinbase_branch/index and, when a parent header is present, // requires it to reproduce that header's tx-merkle-root. The remaining - // steps are NOT built yet: step 2 (confirm the reconstructed MM root is - // committed in the parent coinbase's MM marker, with the chain_id/slot - // binding) and step 4 (parent PoW vs target, consumed from the btc tree - // READ-ONLY). Because those gates do not exist yet, a structurally- - // consistent proof returns INCOMPLETE, never VALID — NMC still MUST NOT - // block-validate off this leaf. Any malformed leg (negative slot index, - // an index wider than its branch depth, or a parent-coinbase leg that - // does not reconstruct the parent header's tx-merkle-root) is INVALID. - (void)expected_chain_id; // step-2 (chain_id/slot) binding lands later + // step 4 (parent PoW vs target, consumed from the btc tree READ-ONLY) is + // NOT built yet. Because step 4 does not exist, a structurally-consistent + // proof returns INCOMPLETE, never VALID — NMC still MUST NOT block- + // validate off this leaf. Any malformed leg (negative slot index, an + // index wider than its branch depth, a parent-coinbase leg that does not + // reconstruct the parent header's tx-merkle-root, or an MM marker that + // commits a different root / wrong tree-size / wrong slot) is INVALID. // ── step 1 (P1b): chain-merkle leg ── if (chain_merkle_index < 0) return CheckResult::INVALID; // negative slot index is malformed + uint256 reconstructed_mm_root; try { - // Reconstructed MM root; its commitment check is step 2 (pending). - (void)aux_merkle_root( + // Reconstruct the merged-mining merkle root; step 2 binds it to the + // parent coinbase commitment. + reconstructed_mm_root = aux_merkle_root( aux_block_hash, chain_merkle_branch, static_cast(chain_merkle_index)); } catch (const std::invalid_argument&) { return CheckResult::INVALID; // branch index out of range for depth } + // ── step 2 (P1c-step2): MM-marker commitment + chain_id/slot binding ── + // Confirm the reconstructed MM root is the one committed inside the + // parent coinbase's merged-mining marker, with the (nonce, chain_id) + // slot binding. When the coinbase carries no marker (a leg-only + // structural fixture, or an empty parent coinbase) there is nothing to + // assert yet — staged posture, mirrors the null-parent-header gate in + // step 3. A marker present but committing a different root / wrong size / + // wrong slot is INVALID. + if (!parent_coinbase.vin.empty()) { + MMScan scan = scan_mm_commitment( + parent_coinbase.vin[0].scriptSig.m_data, + reconstructed_mm_root, + static_cast(chain_merkle_branch.size()), + expected_chain_id, + static_cast(chain_merkle_index)); + if (scan == MMScan::MISMATCH) + return CheckResult::INVALID; + } + // ── step 3 (P1c): parent-coinbase tx-merkle leg ── if (parent_coinbase_index < 0) return CheckResult::INVALID; // negative slot index is malformed diff --git a/src/impl/nmc/test/auxpow_merkle_test.cpp b/src/impl/nmc/test/auxpow_merkle_test.cpp index c53a88267..04b579baf 100644 --- a/src/impl/nmc/test/auxpow_merkle_test.cpp +++ b/src/impl/nmc/test/auxpow_merkle_test.cpp @@ -23,6 +23,7 @@ #include +#include #include #include @@ -271,4 +272,178 @@ TEST(NmcAuxCheckProof, ParentCoinbaseIndexTooWideForDepthIsInvalid) AuxPow::CheckResult::INVALID); } + +// --------------------------------------------------------------------------- +// P1c-step2: AuxPow::check_proof MM-marker commitment + chain_id/slot binding. +// +// step 2 confirms the chain-merkle root reconstructed in step 1 is the one +// committed inside the parent coinbase's merged-mining marker (pchMergedMiningHeader +// = fa be 6d 6d), that the marker's tree size == 2^height, and that the chain +// occupies the slot aux_expected_index(nonce, chain_id, height) demands. A +// coinbase with NO marker leaves the proof INCOMPLETE (staged: nothing to +// assert, mirrors the null-parent-header gate). A marker present but committing +// a different root / wrong size / wrong slot, or a duplicated marker, is INVALID. +// The proof never reaches VALID — step 4 (parent PoW) is still unbuilt. +// +// aux_expected_index is pinned against values computed OFFLINE (not by calling +// the production helper), so an LCG-constant/typo in the port is caught. The +// committed root is laid out here by hand (magic|reversed-root|LE size|LE nonce) +// without touching scan_mm_commitment, so a scan/parse bug is caught not mirrored. +// Per-coin isolation: src/impl/nmc/ only; btc tree consumed READ-ONLY. +// --------------------------------------------------------------------------- + +using nmc::coin::scan_mm_commitment; +using nmc::coin::aux_expected_index; +using nmc::coin::MMScan; + +static const unsigned char MM_MAGIC[4] = {0xfa, 0xbe, 'm', 'm'}; + +static void put_le32(std::vector& v, uint32_t x) +{ + v.push_back(static_cast( x & 0xff)); + v.push_back(static_cast((x >> 8) & 0xff)); + v.push_back(static_cast((x >> 16) & 0xff)); + v.push_back(static_cast((x >> 24) & 0xff)); +} + +// The chain-merkle root as committed: reversed (big-endian display) byte order, +// derived here without touching scan_mm_commitment. +static std::vector root_reversed(uint256 r) +{ + const unsigned char* p = reinterpret_cast(r.begin()); + std::vector v(p, p + uint256::BYTES); + std::reverse(v.begin(), v.end()); + return v; +} + +// [dummy coinbase-height prefix][magic][reversed root][LE size][LE nonce]. +static std::vector mm_script(const std::vector& reversed_root, + uint32_t size, uint32_t nonce) +{ + std::vector s = {0x03, 0x11, 0x22, 0x33}; // arbitrary prefix + s.insert(s.end(), MM_MAGIC, MM_MAGIC + 4); + s.insert(s.end(), reversed_root.begin(), reversed_root.end()); + put_le32(s, size); + put_le32(s, nonce); + return s; +} + +static MutableTransaction coinbase_with_script(const std::vector& script) +{ + MutableTransaction tx; + tx.version = 1; tx.locktime = 0; + TxIn in; + in.prevout.hash.SetNull(); + in.prevout.index = 0xffffffffu; + in.scriptSig = OPScript(script.data(), script.data() + script.size()); + in.sequence = 0xffffffffu; + tx.vin.push_back(in); + TxOut out; out.value = 5000000000LL; tx.vout.push_back(out); + tx.vin[0].scriptWitness.stack.assign(1, std::vector(32, 0x00)); + return tx; +} + +TEST(NmcAuxExpectedIndex, MatchesPinnedOfflineReference) +{ + // values computed offline via the LCG (1103515245 / 12345), chain_id = 1. + EXPECT_EQ(aux_expected_index(0, 1, 3), 3u); + EXPECT_EQ(aux_expected_index(1, 1, 3), 4u); + EXPECT_EQ(aux_expected_index(7, 1, 3), 2u); + EXPECT_EQ(aux_expected_index(0, 1, 1), 1u); + EXPECT_EQ(aux_expected_index(1, 1, 1), 0u); + EXPECT_EQ(aux_expected_index(12345, 1, 4), 12u); +} + +TEST(NmcAuxStep2, ScanReturnsMatchAbsentMismatch) +{ + uint256 aux = leaf_of(0x01), sib = leaf_of(0x55); + uint256 root = combine(aux, sib); // index bit0=0 => leaf left + unsigned h = 1; int32_t cid = 1; uint32_t nonce = 1; uint32_t index = 0; // slot 0 + + auto good = mm_script(root_reversed(root), 1u << h, nonce); + EXPECT_EQ(scan_mm_commitment(good, root, h, cid, index), MMScan::MATCH); + + std::vector nomagic = {0x03, 0x11, 0x22, 0x33}; + EXPECT_EQ(scan_mm_commitment(nomagic, root, h, cid, index), MMScan::ABSENT); + + auto badroot = mm_script(root_reversed(leaf_of(0x99)), 1u << h, nonce); + EXPECT_EQ(scan_mm_commitment(badroot, root, h, cid, index), MMScan::MISMATCH); +} + +TEST(NmcAuxStep2, ValidCommitmentIsIncompleteNeverValid) +{ + uint256 aux = leaf_of(0x01), sib = leaf_of(0x55); + uint256 root = combine(aux, sib); + auto script = mm_script(root_reversed(root), /*size=*/2, /*nonce=*/1); // slot 0 + + AuxPow ap; + ap.parent_coinbase = coinbase_with_script(script); + ap.chain_merkle_branch = {sib}; + ap.chain_merkle_index = 0; + // parent_header null => step 3 equality skipped; step 2 MATCH; step 4 unbuilt. + EXPECT_EQ(ap.check_proof(aux, 1), AuxPow::CheckResult::INCOMPLETE); + EXPECT_NE(ap.check_proof(aux, 1), AuxPow::CheckResult::VALID); +} + +TEST(NmcAuxStep2, WrongCommittedRootIsInvalid) +{ + uint256 aux = leaf_of(0x01), sib = leaf_of(0x55); + auto script = mm_script(root_reversed(leaf_of(0x99)), 2, 1); // commits wrong root + AuxPow ap; + ap.parent_coinbase = coinbase_with_script(script); + ap.chain_merkle_branch = {sib}; + ap.chain_merkle_index = 0; + EXPECT_EQ(ap.check_proof(aux, 1), AuxPow::CheckResult::INVALID); +} + +TEST(NmcAuxStep2, WrongTreeSizeIsInvalid) +{ + uint256 aux = leaf_of(0x01), sib = leaf_of(0x55); + uint256 root = combine(aux, sib); + auto script = mm_script(root_reversed(root), /*size=*/4, /*nonce=*/1); // h=1 wants 2 + AuxPow ap; + ap.parent_coinbase = coinbase_with_script(script); + ap.chain_merkle_branch = {sib}; + ap.chain_merkle_index = 0; + EXPECT_EQ(ap.check_proof(aux, 1), AuxPow::CheckResult::INVALID); +} + +TEST(NmcAuxStep2, WrongDeterministicSlotIsInvalid) +{ + uint256 aux = leaf_of(0x01), sib = leaf_of(0x55); + uint256 root = combine(aux, sib); // correct root for index 0 + // nonce 0 => expected slot 1 (pinned) != chain_merkle_index 0 => binding fails; + // root + size are correct so only the slot check rejects this. + auto script = mm_script(root_reversed(root), 2, /*nonce=*/0); + AuxPow ap; + ap.parent_coinbase = coinbase_with_script(script); + ap.chain_merkle_branch = {sib}; + ap.chain_merkle_index = 0; + EXPECT_EQ(ap.check_proof(aux, 1), AuxPow::CheckResult::INVALID); +} + +TEST(NmcAuxStep2, DuplicateMergedMiningHeaderIsInvalid) +{ + uint256 aux = leaf_of(0x01), sib = leaf_of(0x55); + uint256 root = combine(aux, sib); + auto script = mm_script(root_reversed(root), 2, 1); + script.insert(script.end(), MM_MAGIC, MM_MAGIC + 4); // a second, illegal header + AuxPow ap; + ap.parent_coinbase = coinbase_with_script(script); + ap.chain_merkle_branch = {sib}; + ap.chain_merkle_index = 0; + EXPECT_EQ(ap.check_proof(aux, 1), AuxPow::CheckResult::INVALID); +} + +TEST(NmcAuxStep2, NoMarkerLeavesProofIncomplete) +{ + uint256 aux = leaf_of(0x01), sib = leaf_of(0x55); + std::vector nomagic = {0x03, 0x4e, 0x4d, 0x43}; // no MM magic + AuxPow ap; + ap.parent_coinbase = coinbase_with_script(nomagic); + ap.chain_merkle_branch = {sib}; + ap.chain_merkle_index = 0; + EXPECT_EQ(ap.check_proof(aux, 1), AuxPow::CheckResult::INCOMPLETE); +} + } // namespace From 1e85e7e06626815dfce691ab73441c91168292fd Mon Sep 17 00:00:00 2001 From: frstrtr Date: Fri, 19 Jun 2026 02:52:50 +0000 Subject: [PATCH 4/5] nmc(p1c-step4): wire AuxPow::check_proof parent proof-of-work leg (step 4) Complete the four-leg AuxPow verification walk: verify the parent (BTC) block proof-of-work. Per Namecoin CheckAuxPowProofOfWork the parent block SHA256d hash must satisfy the AUX (NMC) block difficulty target, NOT the parent header own nBits, so check_proof() gains a uint32_t aux_bits param (default 0). The PoW gate fires only with a real parent header and a non-zero aux_bits: pow_hash(parent_header) > bits_to_target(aux_bits) is INVALID (parent has insufficient work); otherwise the leg passes. With all four legs satisfied -- chain-merkle, MM-marker (matched), parent-coinbase tx-merkle, and parent PoW -- against a real parent header, check_proof() now finally returns VALID. Anything short (null parent, no matched marker, or aux_bits unset) stays INCOMPLETE so NMC never block-validates off a partial proof; aux_bits == 0 is the leg-only-fixture sentinel that preserves the staged posture. No btc-tree dependency: chain::bits_to_target lives in src/core and the PoW hash is the nmc-local pow_hash(); src/impl/btc is referenced read-only only. NMC-local per fence #4. +4 KATs (sufficient->VALID, insufficient->INVALID, no-aux_bits->INCOMPLETE, parent-own-bits-are-not-the-gate); the VALID KATs mine the parent nonce against an easy target so the success path is deterministic. nmc_auxpow_merkle_test 26/26 PASS exit 0. --- src/impl/nmc/coin/header_chain.hpp | 49 ++++++++++-- src/impl/nmc/test/auxpow_merkle_test.cpp | 95 ++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 7 deletions(-) diff --git a/src/impl/nmc/coin/header_chain.hpp b/src/impl/nmc/coin/header_chain.hpp index 5d4badbd8..64a6f9945 100644 --- a/src/impl/nmc/coin/header_chain.hpp +++ b/src/impl/nmc/coin/header_chain.hpp @@ -40,6 +40,7 @@ #include #include #include +#include // chain::bits_to_target (parent-PoW, step 4) #include #include @@ -334,7 +335,8 @@ struct AuxPow { /// from the btc tree READ-ONLY (e.g. btc::coin pow check). /// Until all four steps exist, NMC MUST NOT block-validate off this leaf. CheckResult check_proof(const uint256& aux_block_hash, - int32_t expected_chain_id) const { + int32_t expected_chain_id, + uint32_t aux_bits = 0) const { // P1b+P1c — three merkle legs wired. Step 1 (chain-merkle) walks the // aux (NMC) block hash through chain_merkle_branch/index to reconstruct // the merged-mining root; step 2 (MM-marker) confirms that root is the @@ -342,11 +344,13 @@ struct AuxPow { // the (nonce, chain_id) slot binding; step 3 (parent-coinbase tx-merkle) // walks the witness-stripped parent coinbase txid through // parent_coinbase_branch/index and, when a parent header is present, - // requires it to reproduce that header's tx-merkle-root. The remaining - // step 4 (parent PoW vs target, consumed from the btc tree READ-ONLY) is - // NOT built yet. Because step 4 does not exist, a structurally-consistent - // proof returns INCOMPLETE, never VALID — NMC still MUST NOT block- - // validate off this leaf. Any malformed leg (negative slot index, an + // requires it to reproduce that header's tx-merkle-root; step 4 (parent + // PoW) verifies the parent block's SHA256d hash clears the AUX block's + // target. A structurally-complete proof — all four legs, a matched MM + // marker, a real parent header, and an aux_bits that the parent PoW + // satisfies — is VALID. Absent any of those (a leg-only fixture: null + // parent, no marker, or aux_bits unset) the proof stays INCOMPLETE, so + // NMC never block-validates off a partial proof. Any malformed leg (negative slot index, an // index wider than its branch depth, a parent-coinbase leg that does not // reconstruct the parent header's tx-merkle-root, or an MM marker that // commits a different root / wrong tree-size / wrong slot) is INVALID. @@ -373,6 +377,7 @@ struct AuxPow { // assert yet — staged posture, mirrors the null-parent-header gate in // step 3. A marker present but committing a different root / wrong size / // wrong slot is INVALID. + bool mm_marker_matched = false; if (!parent_coinbase.vin.empty()) { MMScan scan = scan_mm_commitment( parent_coinbase.vin[0].scriptSig.m_data, @@ -382,6 +387,7 @@ struct AuxPow { static_cast(chain_merkle_index)); if (scan == MMScan::MISMATCH) return CheckResult::INVALID; + mm_marker_matched = (scan == MMScan::MATCH); } // ── step 3 (P1c): parent-coinbase tx-merkle leg ── @@ -398,11 +404,40 @@ struct AuxPow { } // The equality gate only fires once a parent header is present: a real // proof always carries one, but a leg-only structural fixture leaves it - // null and has nothing to match against yet (steps 2/4 still pending). + // null and has nothing to match against yet (step 2 may also pend). if (!parent_header.IsNull() && reconstructed_parent_merkle != parent_header.m_merkle_root) return CheckResult::INVALID; + // ── step 4 (P1c-step4): parent proof-of-work vs the AUX target ── + // The parent (BTC) block's SHA256d PoW hash is the work backing this + // Namecoin block. Per Namecoin's CheckAuxPowProofOfWork the parent PoW + // hash must satisfy the AUX (NMC) block's difficulty target — aux_bits, + // the NMC header's nBits — NOT the parent header's own nBits. Target is + // expanded with the shared core helper chain::bits_to_target(); the hash + // is the nmc-local pow_hash(parent_header). No btc-tree dependency: both + // helpers live outside src/impl/btc (core + nmc-local). + // + // aux_bits == 0 is the leg-only-fixture sentinel (caller has not supplied + // the aux header's difficulty): step 4 is skipped and the proof stays + // INCOMPLETE, mirroring the null-parent / no-marker gates above. + bool parent_pow_ok = false; + if (!parent_header.IsNull() && aux_bits != 0) { + uint256 parent_powhash = pow_hash(parent_header); + uint256 aux_target = chain::bits_to_target(aux_bits); + // Valid PoW means hash <= target, i.e. NOT (hash > target) — same + // ordering convention as the btc work_source block/share gate. + if (parent_powhash > aux_target) + return CheckResult::INVALID; // parent has insufficient work + parent_pow_ok = true; + } + + // Only a structurally-complete proof is VALID: all four legs satisfied + // against a real parent header, a matched MM-marker commitment, and a + // parent PoW that clears the aux target. Anything short stays INCOMPLETE. + if (parent_pow_ok && mm_marker_matched && !parent_header.IsNull()) + return CheckResult::VALID; + return CheckResult::INCOMPLETE; } }; diff --git a/src/impl/nmc/test/auxpow_merkle_test.cpp b/src/impl/nmc/test/auxpow_merkle_test.cpp index 04b579baf..52f18d31e 100644 --- a/src/impl/nmc/test/auxpow_merkle_test.cpp +++ b/src/impl/nmc/test/auxpow_merkle_test.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include "../coin/header_chain.hpp" @@ -37,6 +38,7 @@ namespace { using nmc::coin::aux_merkle_root; using nmc::coin::AuxPow; +using nmc::coin::pow_hash; // Build a uint256 whose first byte is `b` (rest zero) — distinct, legible leaves. static uint256 leaf_of(unsigned char b) @@ -446,4 +448,97 @@ TEST(NmcAuxStep2, NoMarkerLeavesProofIncomplete) EXPECT_EQ(ap.check_proof(aux, 1), AuxPow::CheckResult::INCOMPLETE); } +// --------------------------------------------------------------------------- +// P1c-step4: AuxPow::check_proof parent proof-of-work leg (step 4). +// +// The parent (BTC) block's SHA256d PoW hash must clear the AUX (NMC) block's +// difficulty target (Namecoin CheckAuxPowProofOfWork: the parent PoW is checked +// against the aux bits, NOT the parent's own bits). With steps 1-3 satisfied, +// a sufficient parent PoW finally yields VALID; an insufficient one is INVALID; +// and a caller that supplies no aux_bits (default 0) keeps the otherwise- +// complete proof INCOMPLETE so NMC never block-validates off a partial proof. +// Per-coin isolation: src/impl/nmc/ only; chain::bits_to_target is core, the +// PoW hash is nmc-local pow_hash() -- the btc tree is not touched. +// --------------------------------------------------------------------------- + +// A structurally-complete AuxPow whose steps 1-3 all pass: a parent coinbase +// carrying the MM marker committing the chain-merkle root (slot 0: nonce=1, +// chain_id=1, height=1), itself linked into the parent header tx-merkle-root. +static AuxPow complete_proof(uint256 aux, uint32_t parent_own_bits) +{ + uint256 sib = leaf_of(0x55); + uint256 root = combine(aux, sib); // chain-merkle root + auto script = mm_script(root_reversed(root), /*size=*/2, /*nonce=*/1); + + AuxPow ap; + ap.parent_coinbase = coinbase_with_script(script); + ap.chain_merkle_branch = {sib}; + ap.chain_merkle_index = 0; // expected slot 0 + + uint256 cbid = parent_coinbase_txid(ap.parent_coinbase); + uint256 sib2 = leaf_of(0x77); + ap.parent_coinbase_branch = {sib2}; + ap.parent_coinbase_index = 0; // coinbase LEFT + ap.parent_header.m_merkle_root = combine(cbid, sib2); + ap.parent_header.m_bits = parent_own_bits; // non-null header + return ap; +} + +// "Mine" a parent header against an easy target: bump m_nonce until the +// SHA256d PoW clears `target`. Touches ONLY m_nonce, so the merkle legs +// (steps 1-3) are unaffected. At regtest-style difficulty this lands in a +// couple of iterations; the bound makes it deterministic-or-loud. +static bool mine_parent(AuxPow& ap, const uint256& target) +{ + for (uint32_t n = 0; n < 100000u; ++n) { + ap.parent_header.m_nonce = n; + if (!(pow_hash(ap.parent_header) > target)) return true; + } + return false; +} + +TEST(NmcAuxStep4, CompleteProofWithSufficientParentWorkIsValid) +{ + uint256 aux = leaf_of(0x01); + AuxPow ap = complete_proof(aux, /*parent_own_bits=*/0x1d00ffffu); + uint32_t aux_bits = 0x207fffffu; // regtest-style easy target + ASSERT_TRUE(mine_parent(ap, chain::bits_to_target(aux_bits))); + EXPECT_EQ(ap.check_proof(aux, 1, aux_bits), AuxPow::CheckResult::VALID); +} + +TEST(NmcAuxStep4, CompleteProofWithInsufficientParentWorkIsInvalid) +{ + uint256 aux = leaf_of(0x01); + AuxPow ap = complete_proof(aux, 0x1d00ffffu); + // Maximally-hard aux target (compact for target == 1). A real double-SHA256 + // parent hash exceeds it (guarded), so the parent fails the aux PoW. + uint32_t hard_bits = 0x03000001u; // bits_to_target => 1 + uint256 pow = pow_hash(ap.parent_header); + ASSERT_TRUE(pow > chain::bits_to_target(hard_bits)); // precondition + EXPECT_EQ(ap.check_proof(aux, 1, hard_bits), AuxPow::CheckResult::INVALID); +} + +TEST(NmcAuxStep4, CompleteProofWithoutAuxBitsStaysIncomplete) +{ + uint256 aux = leaf_of(0x01); + AuxPow ap = complete_proof(aux, 0x1d00ffffu); + // Default aux_bits (0): the parent-PoW gate is skipped and the otherwise- + // complete proof stays INCOMPLETE -- never VALID. + EXPECT_EQ(ap.check_proof(aux, 1), AuxPow::CheckResult::INCOMPLETE); + EXPECT_NE(ap.check_proof(aux, 1), AuxPow::CheckResult::VALID); +} + +TEST(NmcAuxStep4, ParentOwnBitsAreNotTheAuxGate) +{ + // Parent header's OWN nBits set maximally hard (target == 1); if check_proof + // wrongly used the parent's own bits the proof would be INVALID. The AUX + // target (easy) governs instead -- pinning the Namecoin consensus rule that + // the parent PoW is checked against the aux bits, not the parent's own. + uint256 aux = leaf_of(0x01); + AuxPow ap = complete_proof(aux, /*parent_own_bits=*/0x03000001u); + uint32_t aux_bits = 0x207fffffu; // easy aux target + ASSERT_TRUE(mine_parent(ap, chain::bits_to_target(aux_bits))); + EXPECT_EQ(ap.check_proof(aux, 1, aux_bits), AuxPow::CheckResult::VALID); +} + } // namespace From be5b1efe3b05994325168752eaecb1734af65ed0 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Fri, 19 Jun 2026 04:47:38 +0000 Subject: [PATCH 5/5] nmc(P1d): wire AuxPow::check_proof gate into add_auxpow_header verify_auxpow_header() factors the four-leg AuxPow::check_proof into a unit-testable consensus gate: an incoming merge-mined header is admissible only when check_proof(block_hash(header), aux_chain_id, header.m_bits) returns VALID. add_auxpow_header() now rejects (warns + returns false) on any non-VALID verdict; header storage/connection and the height-derived is_auxpow_active() activation gate remain P0-DEFER (need a connected parent). P1d ships the rejection half: nothing unproven gets in. +4 KATs (NmcP1dGate) bind the aux hash to a real header via the block_hash() production seam: fully-verified proof passes; wrong chain_id, insufficient parent work, and missing aux_bits are each rejected. Suite 26->30. NMC-only under src/impl/nmc/; btc tree consumed READ-ONLY. --- src/impl/nmc/coin/header_chain.hpp | 36 +++++++-- src/impl/nmc/test/auxpow_merkle_test.cpp | 94 ++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 8 deletions(-) diff --git a/src/impl/nmc/coin/header_chain.hpp b/src/impl/nmc/coin/header_chain.hpp index 64a6f9945..bf06ccb93 100644 --- a/src/impl/nmc/coin/header_chain.hpp +++ b/src/impl/nmc/coin/header_chain.hpp @@ -633,16 +633,36 @@ class HeaderChain { return false; } + /// Verify the AuxPow consensus GATE for an incoming merge-mined header. + /// P1d: factored out so the gate is unit-testable independently of the + /// (still-deferred) header-storage path. Runs the full four-leg + /// AuxPow::check_proof against the AUX block hash (block_hash(header)), this + /// chain's claimed aux_chain_id, and the header's own nBits as the aux PoW + /// target (Namecoin checks the parent PoW against the AUX bits, not the + /// parent's own). A header is admissible ONLY when this returns VALID. + AuxPow::CheckResult verify_auxpow_header(const BlockHeaderType& header, + const AuxPow& auxpow) const { + return auxpow.check_proof(block_hash(header), m_params.aux_chain_id, + header.m_bits); + } + /// Add a header that carries a merge-mining AuxPow proof. - /// P0-DEFER: stores nothing and verifies nothing. The real path must call - /// AuxPow::check_proof() (itself a P0 stub) before accepting. + /// P1d: the AuxPow VERIFICATION GATE is now wired - check_proof() must + /// return VALID or the header is rejected, so the (future) accept path can + /// never admit an unproven merge-mined header. The header-storage / + /// chain-connection path, and the height-derived is_auxpow_active() + /// activation gate (which needs a connected parent), remain P0-DEFER - see + /// add_header(). A header that passes the gate is therefore NOT persisted + /// yet; P1d's contract is the rejection half: nothing unproven gets in. bool add_auxpow_header(const BlockHeaderType& header, const AuxPow& auxpow) { - (void)header; - (void)auxpow; - // P0-DEFER: auxpow header add/validate path not implemented. - // NOTE: even when built, must reject unless params.is_auxpow_active(h) - // and auxpow.check_proof(block_hash(header), params.aux_chain_id) - // == AuxPow::CheckResult::VALID. + if (verify_auxpow_header(header, auxpow) != AuxPow::CheckResult::VALID) { + LOG_WARNING << "[EMB-NMC] reject auxpow header " + << block_hash(header).ToString() + << ": AuxPow check_proof != VALID"; + return false; + } + // P0-DEFER: proof verified, but header storage/connection + the + // is_auxpow_active(height) activation gate are not built yet. return false; } diff --git a/src/impl/nmc/test/auxpow_merkle_test.cpp b/src/impl/nmc/test/auxpow_merkle_test.cpp index 52f18d31e..5ebc00f5f 100644 --- a/src/impl/nmc/test/auxpow_merkle_test.cpp +++ b/src/impl/nmc/test/auxpow_merkle_test.cpp @@ -541,4 +541,98 @@ TEST(NmcAuxStep4, ParentOwnBitsAreNotTheAuxGate) EXPECT_EQ(ap.check_proof(aux, 1, aux_bits), AuxPow::CheckResult::VALID); } +// --------------------------------------------------------------------------- +// P1d - HeaderChain::add_auxpow_header / verify_auxpow_header consensus GATE. +// +// The gate wires AuxPow::check_proof into the header-accept path: an incoming +// merge-mined header is admissible ONLY when its proof fully verifies against +// THIS chain's params (aux_chain_id) and the header's own nBits as the aux PoW +// target. These KATs pin the rejection contract - nothing unproven gets in - +// reusing the step-4 complete_proof/mine_parent fixtures but binding the aux +// block hash to a REAL header via block_hash(header), the production seam. +// Storage stays P0-DEFER, so a verified header is not persisted yet (add +// returns false on both arms); the assertion is on the verify verdict. +// --------------------------------------------------------------------------- + +using nmc::coin::HeaderChain; +using nmc::coin::NMCChainParams; +using nmc::coin::BlockHeaderType; +using nmc::coin::block_hash; + +// Params with a pinned aux_chain_id == 1 so the fixture's MM-marker slot +// (chain_id=1) binds. Activation height stays unpinned: the P1d gate under test +// is the cryptographic check_proof, not the (deferred) height-activation path. +static NMCChainParams params_chain_id_1() +{ + NMCChainParams p = NMCChainParams::mainnet(); + p.aux_chain_id = 1; + return p; +} + +// A header whose block_hash() becomes the AUX hash the proof commits to; m_bits +// carries the aux PoW target the parent hash must clear (Namecoin aux-bits rule). +static BlockHeaderType aux_header_for(uint32_t aux_bits) +{ + BlockHeaderType h{}; + h.m_version = 1; + h.m_bits = aux_bits; + return h; +} + +TEST(NmcP1dGate, FullyVerifiedAuxPowPassesTheGate) +{ + uint32_t aux_bits = 0x207fffffu; // regtest-style easy target + BlockHeaderType h = aux_header_for(aux_bits); + uint256 aux = block_hash(h); // production seam + AuxPow ap = complete_proof(aux, /*parent_own_bits=*/0x1d00ffffu); + ASSERT_TRUE(mine_parent(ap, chain::bits_to_target(aux_bits))); + + HeaderChain chain_(params_chain_id_1()); + EXPECT_EQ(chain_.verify_auxpow_header(h, ap), AuxPow::CheckResult::VALID); + // Storage is still P0-DEFER, so even a verified header is not persisted yet. + EXPECT_FALSE(chain_.add_auxpow_header(h, ap)); + EXPECT_FALSE(chain_.has_header(aux)); +} + +TEST(NmcP1dGate, WrongChainIdIsNotValidAndIsRejected) +{ + uint32_t aux_bits = 0x207fffffu; + BlockHeaderType h = aux_header_for(aux_bits); + uint256 aux = block_hash(h); + AuxPow ap = complete_proof(aux, 0x1d00ffffu); + ASSERT_TRUE(mine_parent(ap, chain::bits_to_target(aux_bits))); + + NMCChainParams p = NMCChainParams::mainnet(); + p.aux_chain_id = 2; // != fixture's chain_id 1 + HeaderChain chain_(p); + EXPECT_NE(chain_.verify_auxpow_header(h, ap), AuxPow::CheckResult::VALID); + EXPECT_FALSE(chain_.add_auxpow_header(h, ap)); +} + +TEST(NmcP1dGate, InsufficientParentWorkIsRejected) +{ + BlockHeaderType h = aux_header_for(/*aux_bits=*/0x03000001u); // target == 1 + uint256 aux = block_hash(h); + AuxPow ap = complete_proof(aux, 0x1d00ffffu); + // Unmined parent: a real double-SHA256 hash exceeds target 1 => INVALID. + ASSERT_TRUE(pow_hash(ap.parent_header) > chain::bits_to_target(h.m_bits)); + + HeaderChain chain_(params_chain_id_1()); + EXPECT_EQ(chain_.verify_auxpow_header(h, ap), AuxPow::CheckResult::INVALID); + EXPECT_FALSE(chain_.add_auxpow_header(h, ap)); +} + +TEST(NmcP1dGate, MissingAuxBitsKeepsProofIncompleteAndRejected) +{ + BlockHeaderType h = aux_header_for(/*aux_bits=*/0u); // 0 == leg-only sentinel + uint256 aux = block_hash(h); + AuxPow ap = complete_proof(aux, 0x1d00ffffu); + + HeaderChain chain_(params_chain_id_1()); + // header.m_bits == 0 feeds check_proof's leg-only sentinel: step 4 skipped, + // the proof stays INCOMPLETE, and the gate rejects. + EXPECT_EQ(chain_.verify_auxpow_header(h, ap), AuxPow::CheckResult::INCOMPLETE); + EXPECT_FALSE(chain_.add_auxpow_header(h, ap)); +} + } // namespace