diff --git a/src/impl/nmc/coin/header_chain.hpp b/src/impl/nmc/coin/header_chain.hpp index efd682d09..2175400da 100644 --- a/src/impl/nmc/coin/header_chain.hpp +++ b/src/impl/nmc/coin/header_chain.hpp @@ -41,12 +41,15 @@ #include #include #include // chain::bits_to_target (parent-PoW, step 4) +#include // P1f: core::LevelDBStore persistence #include #include #include +#include #include #include +#include #include #include #include @@ -553,6 +556,78 @@ struct IndexEntry { std::optional auxpow; // merge-mining proof (P0: stored, unverified) }; +/// P1f on-disk layout for an IndexEntry (mirror of btc::coin::IndexEntryDiskV1, +/// adapted to NMC). Serialize order: header, block_hash, height, chain_work, +/// status (as uint32_t), then the AuxPow presence flag (uint8_t: 1 if the entry +/// carried a merge-mining proof, else 0). +/// +/// P1f-DEFER: auxpow blob persistence -- next sub-leg. nmc::coin::AuxPow itself +/// has NO ::Serialize/::Unserialize / C2POOL_SERIALIZE_METHODS, so per the +/// fence we do NOT hand-roll its wire layout here. This leg persists ONLY the +/// presence flag; on load the AuxPow is reconstructed as std::nullopt, but the +/// entry's status (HEADER_VALID_CHAIN for a merge-mined header) is preserved +/// faithfully so the restored chain's validation state is not lost. The actual +/// proof blob lands in the next sub-leg behind this same flag. +struct IndexEntryDiskV1 { + BlockHeaderType header; + uint256 block_hash; // SHA256d(header) + uint32_t height{0}; + uint256 chain_work; // cumulative work up to this header + HeaderStatus status{HEADER_VALID_UNKNOWN}; + uint8_t has_auxpow{0}; // presence flag (blob deferred -- see above) + + template + void Serialize(Stream& s) const { + ::Serialize(s, header); + ::Serialize(s, block_hash); + ::Serialize(s, height); + ::Serialize(s, chain_work); + ::Serialize(s, static_cast(status)); + ::Serialize(s, has_auxpow); + // P1f-DEFER: auxpow blob persistence -- next sub-leg. Nothing follows the + // flag yet; when AuxPow gains serialization the blob is written here iff + // has_auxpow == 1. + } + template + void Unserialize(Stream& s) { + ::Unserialize(s, header); + ::Unserialize(s, block_hash); + ::Unserialize(s, height); + ::Unserialize(s, chain_work); + uint32_t st; + ::Unserialize(s, st); + status = static_cast(st); + ::Unserialize(s, has_auxpow); + // P1f-DEFER: auxpow blob persistence -- next sub-leg. The blob is not on + // disk yet, so there is nothing further to read behind the flag. + } + + /// Materialize the slim in-memory form. auxpow is reconstructed as nullopt + /// in this leg (blob deferred); status carries the merge-mined verdict. + IndexEntry to_entry() const { + IndexEntry e; + e.header = header; + e.block_hash = block_hash; + e.height = height; + e.chain_work = chain_work; + e.status = status; + e.auxpow = std::nullopt; // P1f-DEFER: blob not persisted yet + return e; + } + + /// Build the on-disk form from a slim entry. + static IndexEntryDiskV1 from_entry(const IndexEntry& e) { + IndexEntryDiskV1 d; + d.header = e.header; + d.block_hash = e.block_hash; + d.height = e.height; + d.chain_work = e.chain_work; + d.status = e.status; + d.has_auxpow = e.auxpow.has_value() ? 1 : 0; + return d; + } +}; + /// Work represented by a compact target. Mirror of btc::coin::get_block_proof /// (src/impl/btc/coin/header_chain.hpp) — NMC is a SHA256d Bitcoin fork, so the /// work metric is identical. Kept local to the NMC lane (btc tree is read-only; @@ -591,9 +666,24 @@ class HeaderChain { /// P0-DEFER: no LevelDB open, no persisted-state load, no checkpoint seed. /// Returns true (structural success) so wiring smoke-tests pass. bool init() { - LOG_INFO << "[EMB-NMC] HeaderChain::init() (P0 structural stub) db_path=" + LOG_INFO << "[EMB-NMC] HeaderChain::init() db_path=" << (m_db_path.empty() ? "(in-memory)" : m_db_path); - // P0-DEFER: persistence + checkpoint-seed not implemented. + if (m_db_path.empty()) + return true; // pure in-memory mode + + core::LevelDBOptions opts; + opts.write_buffer_size = 2 * 1024 * 1024; // 2MB + opts.block_cache_size = 4 * 1024 * 1024; // 4MB + m_db = std::make_unique(m_db_path, opts); + if (!m_db->open()) { + // Non-fatal (mirror of btc): degrade to in-memory and keep running. + LOG_WARNING << "[EMB-NMC] HeaderChain LevelDB open FAILED at " + << m_db_path << " -- continuing in-memory"; + m_db.reset(); + return true; + } + LOG_INFO << "[EMB-NMC] HeaderChain LevelDB opened at " << m_db_path; + load_from_db(); return true; } @@ -829,6 +919,7 @@ class HeaderChain { // min-difficulty testnet where every block carries identical work. // Re-receiving a stored header is the idempotent reject above, so the // switch cannot flip-flop. + bool tip_changed = false; const bool first = m_tip.IsNull(); const bool dominated = entry_work > m_best_work; const bool equal_at_tip = entry_work == m_best_work @@ -840,6 +931,7 @@ class HeaderChain { m_tip = bh; m_tip_height = static_cast(height); m_best_work = entry_work; // cumulative work at the new tip + tip_changed = true; if (!first && (equal_at_tip || height <= static_cast(old_height))) { LOG_WARNING << "[EMB-NMC] " @@ -849,11 +941,111 @@ class HeaderChain { << " new_tip=" << bh.ToString().substr(0, 16); } } + + // P1f: persist the newly-accepted header (and, if the tip moved, the + // tip/height pointers) atomically. Only on this success path -- the + // idempotent / orphan / activation-gate early returns above never reach + // here, so nothing is written for a rejected header. + persist_entry_locked(bh, tip_changed); return true; } + // P1f: write a single accepted entry to LevelDB in one synced batch. Caller + // holds m_mutex and has already inserted the entry into m_index. No-op when + // running in pure in-memory mode (m_db null / not open). + void persist_entry_locked(const uint256& bh, bool tip_changed) { + if (!m_db || !m_db->is_open()) return; + auto eit = m_index.find(bh); + if (eit == m_index.end()) return; // defensive -- should never miss + + auto batch = m_db->create_batch(); + + // "h" + block_hash(32 raw bytes) -> serialized IndexEntryDiskV1. + auto disk = IndexEntryDiskV1::from_entry(eit->second); + auto packed = pack(disk); + std::vector data( + reinterpret_cast(packed.data()), + reinterpret_cast(packed.data()) + packed.size()); + std::string hkey = "h"; + hkey.append(reinterpret_cast(bh.data()), 32); + batch.put(hkey, data); + + if (tip_changed) { + std::vector tip_data(m_tip.data(), m_tip.data() + 32); + batch.put("tip", tip_data); + + const uint32_t h = m_tip_height; + std::vector height_data(4); + height_data[0] = static_cast((h >> 24) & 0xFF); + height_data[1] = static_cast((h >> 16) & 0xFF); + height_data[2] = static_cast((h >> 8) & 0xFF); + height_data[3] = static_cast( h & 0xFF); + batch.put("height", height_data); + } + + if (!batch.commit_sync()) + LOG_ERROR << "[EMB-NMC] persist_entry_locked: WriteBatch FAILED for " + << bh.ToString().substr(0, 16); + } + + // P1f: restore m_index / m_tip / m_tip_height / m_best_work from LevelDB. + // Schema (NMC has no height-index, so NO "i" key): + // "h" + block_hash(32 raw bytes) -> serialized IndexEntryDiskV1 + // "tip" -> block_hash(32 raw bytes) + // "height" -> uint32_t, 4 bytes big-endian + // Caller is init() before any concurrent access; no lock held / needed. + void load_from_db() { + if (!m_db || !m_db->is_open()) return; + + // Enumerate every stored header under the "h" prefix and rebuild m_index. + auto h_keys = m_db->list_keys("h", 10000000); + size_t loaded = 0; + for (auto& k : h_keys) { + if (k.size() != 33 || k[0] != 'h') continue; // "h" + 32 raw bytes + std::vector data; + if (!m_db->get(k, data)) continue; + try { + PackStream ps(data); + IndexEntryDiskV1 disk; + ps >> disk; + IndexEntry e = disk.to_entry(); + const uint256 bh = e.block_hash; + m_index.emplace(bh, std::move(e)); + ++loaded; + } catch (const std::exception& ex) { + LOG_WARNING << "[EMB-NMC] load_from_db: corrupt header entry, skipped: " + << ex.what(); + } + } + + // Restore the tip pointer (32 raw bytes) and height (4 BE bytes). + std::vector tip_data; + if (m_db->get("tip", tip_data) && tip_data.size() == 32) { + std::memcpy(m_tip.data(), tip_data.data(), 32); + auto tit = m_index.find(m_tip); + if (tit == m_index.end()) { + LOG_WARNING << "[EMB-NMC] load_from_db: tip hash not in index -- resetting"; + m_tip.SetNull(); + } else { + m_best_work = tit->second.chain_work; // recompute best-work from tip + } + } + std::vector height_data; + if (!m_tip.IsNull() && m_db->get("height", height_data) && height_data.size() == 4) { + m_tip_height = (static_cast(height_data[0]) << 24) + | (static_cast(height_data[1]) << 16) + | (static_cast(height_data[2]) << 8) + | static_cast(height_data[3]); + } + + LOG_INFO << "[EMB-NMC] load_from_db: loaded " << loaded << " headers" + << " tip=" << (m_tip.IsNull() ? "(null)" : m_tip.ToString().substr(0, 16)) + << " height=" << m_tip_height; + } + NMCChainParams m_params; std::string m_db_path; + std::unique_ptr m_db; // P1f: null in pure in-memory mode mutable std::mutex m_mutex; std::unordered_map m_index; diff --git a/src/impl/nmc/test/auxpow_merkle_test.cpp b/src/impl/nmc/test/auxpow_merkle_test.cpp index 3f81cfa1c..a288fee38 100644 --- a/src/impl/nmc/test/auxpow_merkle_test.cpp +++ b/src/impl/nmc/test/auxpow_merkle_test.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include @@ -1116,4 +1117,172 @@ TEST(NmcP1fLocator, LocatorFollowsTheTipAfterAWorkReorg) EXPECT_EQ(std::count(loc.begin(), loc.end(), block_hash(a2)), 0); } + +// --------------------------------------------------------------------------- +// P1f LevelDB persistence leg KATs (NmcP1fPersist). The in-memory connect path +// now writes every accepted header under "h"+block_hash plus "tip"/"height" +// pointers in one synced batch; init() reloads them. NMC has no height-index, +// so there is deliberately NO "i" key (full-residency m_index, unlike btc). +// Auxpow blobs are NOT persisted yet -- only a presence flag -- so a reloaded +// merge-mined header keeps its VALID_CHAIN status but its auxpow comes back +// nullopt (documented P1f-DEFER). The disk-format round-trips are pure (no DB); +// the reopen round-trips exercise the real LevelDBStore under a temp dir. +// --------------------------------------------------------------------------- +using nmc::coin::IndexEntry; +using nmc::coin::IndexEntryDiskV1; + +// A fresh, empty on-disk path for one test (prior contents wiped). +static std::string fresh_db_dir(const std::string& name) +{ + std::filesystem::path p = std::filesystem::path(testing::TempDir()) / name; + std::error_code ec; + std::filesystem::remove_all(p, ec); + return p.string(); +} + +TEST(NmcP1fPersist, IndexEntryDiskRoundTripsViaPackStream) +{ + IndexEntry e; + e.header = plain_header(leaf_of(0x11), kEasyBits, 42); + e.block_hash = block_hash(e.header); + e.height = 7; + e.chain_work = nmc::coin::get_block_proof(kEasyBits); + e.status = nmc::coin::HEADER_VALID_TREE; + e.auxpow = std::nullopt; + + auto disk = IndexEntryDiskV1::from_entry(e); + EXPECT_EQ(disk.has_auxpow, 0); + + auto packed = pack(disk); + std::vector data( + reinterpret_cast(packed.data()), + reinterpret_cast(packed.data()) + packed.size()); + PackStream ps(data); + IndexEntryDiskV1 back; + ps >> back; + IndexEntry r = back.to_entry(); + + EXPECT_EQ(r.block_hash, e.block_hash); + EXPECT_EQ(r.height, e.height); + EXPECT_EQ(r.chain_work, e.chain_work); + EXPECT_EQ(r.status, e.status); + EXPECT_FALSE(r.auxpow.has_value()); +} + +TEST(NmcP1fPersist, DiskRoundTripPreservesMergeMinedStatusAndAuxFlag) +{ + IndexEntry e; + e.header = plain_header(leaf_of(0x22), kEasyBits, 9); + e.block_hash = block_hash(e.header); + e.height = 19200; + e.chain_work = nmc::coin::get_block_proof(kEasyBits); + e.status = nmc::coin::HEADER_VALID_CHAIN; // merge-mined verdict + e.auxpow = complete_proof(e.block_hash, 0x1d00ffffu); + + auto disk = IndexEntryDiskV1::from_entry(e); + EXPECT_EQ(disk.has_auxpow, 1); // flag tracks presence + + auto packed = pack(disk); + std::vector data( + reinterpret_cast(packed.data()), + reinterpret_cast(packed.data()) + packed.size()); + PackStream ps(data); + IndexEntryDiskV1 back; + ps >> back; + EXPECT_EQ(back.has_auxpow, 1); + + IndexEntry r = back.to_entry(); + EXPECT_EQ(r.status, nmc::coin::HEADER_VALID_CHAIN); // status survives reload + EXPECT_FALSE(r.auxpow.has_value()); // P1f-DEFER: blob not on disk +} + +TEST(NmcP1fPersist, ReopenRestoresEmptyChainAsEmpty) +{ + const std::string dir = fresh_db_dir("nmc_p1f_empty"); + { + HeaderChain a(params_activation(19200), dir); + ASSERT_TRUE(a.init()); + EXPECT_EQ(a.size(), 0u); + } + { + HeaderChain b(params_activation(19200), dir); + ASSERT_TRUE(b.init()); + EXPECT_EQ(b.size(), 0u); + EXPECT_FALSE(b.tip().has_value()); + EXPECT_EQ(b.height(), 0u); + } +} + +TEST(NmcP1fPersist, SingleHeaderSurvivesReopen) +{ + const std::string dir = fresh_db_dir("nmc_p1f_single"); + uint256 z; z.SetNull(); + BlockHeaderType g = plain_header(z, kEasyBits, 1); + { + HeaderChain a(params_activation(19200), dir); + ASSERT_TRUE(a.init()); + ASSERT_TRUE(a.add_header(g)); + ASSERT_EQ(a.size(), 1u); + } + { + HeaderChain b(params_activation(19200), dir); + ASSERT_TRUE(b.init()); + EXPECT_EQ(b.size(), 1u); + EXPECT_EQ(b.height(), 0u); + EXPECT_TRUE(b.has_header(block_hash(g))); + ASSERT_TRUE(b.tip().has_value()); + EXPECT_EQ(b.tip()->block_hash, block_hash(g)); + } +} + +TEST(NmcP1fPersist, MultiHeaderChainRestoresTipHeightAndWork) +{ + const std::string dir = fresh_db_dir("nmc_p1f_multi"); + uint256 z; z.SetNull(); + BlockHeaderType g = plain_header(z, kEasyBits, 1); + BlockHeaderType c1 = plain_header(block_hash(g), kEasyBits, 2); + BlockHeaderType c2 = plain_header(block_hash(c1), kEasyBits, 3); + uint256 want_work; + { + HeaderChain a(params_activation(19200), dir); + ASSERT_TRUE(a.init()); + ASSERT_TRUE(a.add_header(g)); + ASSERT_TRUE(a.add_header(c1)); + ASSERT_TRUE(a.add_header(c2)); + ASSERT_EQ(a.height(), 2u); + want_work = a.cumulative_work(); + } + { + HeaderChain b(params_activation(19200), dir); + ASSERT_TRUE(b.init()); + EXPECT_EQ(b.size(), 3u); // "height" key not mis-parsed as a header + EXPECT_EQ(b.height(), 2u); + EXPECT_EQ(b.cumulative_work(), want_work); + ASSERT_TRUE(b.tip().has_value()); + EXPECT_EQ(b.tip()->block_hash, block_hash(c2)); + EXPECT_TRUE(b.has_header(block_hash(g))); + EXPECT_TRUE(b.has_header(block_hash(c1))); + EXPECT_TRUE(b.has_header(block_hash(c2))); + ASSERT_TRUE(b.get_header(block_hash(c2)).has_value()); + EXPECT_EQ(b.get_header(block_hash(c2))->height, 2u); + } +} + +TEST(NmcP1fPersist, InMemoryModeWithoutDbPathPersistsNothing) +{ + uint256 z; z.SetNull(); + BlockHeaderType g = plain_header(z, kEasyBits, 1); + { + HeaderChain mem(params_activation(19200), ""); // no db_path -> pure in-memory + ASSERT_TRUE(mem.init()); + ASSERT_TRUE(mem.add_header(g)); + EXPECT_EQ(mem.size(), 1u); + } + // A fresh in-memory chain shares no on-disk state -- it starts empty. + HeaderChain fresh(params_activation(19200), ""); + ASSERT_TRUE(fresh.init()); + EXPECT_EQ(fresh.size(), 0u); + EXPECT_FALSE(fresh.has_header(block_hash(g))); +} + } // namespace