diff --git a/src/c2pool/main_dash.cpp b/src/c2pool/main_dash.cpp index 79a8b54e1..0d3fb6f82 100644 --- a/src/c2pool/main_dash.cpp +++ b/src/c2pool/main_dash.cpp @@ -97,6 +97,7 @@ #include // E2d: checkpoint -> forward-replay bridge -> leg-4 publish #include // W2: full-history replay bulk block-fetch lane (--replay-bulk) #include // W3: full-history replay standalone UTXO fold (--replay-utxo-*) +#include // W5-A: graduated fold → mempool fee pricing (--embedded-utxo-fold-fees) #include // W5: anchor prestate loader (--replay-fold-prestate) #include // W5: bulk lane -> W1 DML fold + per-block root check #include // SEAM: W4 quorum lane <-> W1 MembersFn @@ -410,6 +411,7 @@ void print_banner(const char* argv0) << " [--embedded-utxo-immature-serve-empty] [--embedded-serve-mempool-txs]\n" << " [--embedded-accrue-asset-locks] [--embedded-accrue-asset-unlocks]\n" << " [--embedded-ingest-isdlock]\n" + << " [--embedded-utxo-fold-fees DBPATH --embedded-utxo-fold-expect HEX]\n" << " [--pin-local-tx-hex FILE] (zero-fee self-mined tx, e.g. donation consolidation)\n" << " [--pin-splice-xcheck-arm] (let pins ride an xcheck-SWAPPED dashd template; default OFF)\n" << " [--pin-splice-block-budget] (EXCLUDE a pin that pushes the template past the block size cap; default OFF)\n" @@ -900,7 +902,25 @@ int run_node(bool testnet, const std::string& rpc_endpoint, // individually BLS-gated against the rotated LLMQ_60_75 quorum // dashd's SelectQuorumForSigning designates (islock_verify.hpp; // fail-closed at every hop). - bool embedded_ingest_isdlock = false) + bool embedded_ingest_isdlock = false, + // --embedded-utxo-fold-fees DBPATH (+ REQUIRED + // --embedded-utxo-fold-expect HEX): W5-A. Open the W3 full- + // history UTXO fold at DBPATH and install FoldFeeSource as the + // mempool's THIRD coin source for fee pricing + selection vin + // resolution — closing the dominant "coin older than the replay + // horizon ⇒ fee-unknown ⇒ excluded" dashd-only tx class behind + // the gbt-xcheck tx-merkle mismatches. DEFAULT OFF (empty): + // nothing is constructed, Mempool::m_fee_coin_lookup stays + // empty, production byte-identical. Even armed, the source + // answers NOTHING until the fold GRADUATES (hash_serialized_2 + // byte-equal to dashd's gettxoutsetinfo at the pinned anchor — + // the operator must RESTATE the expected hash in + // --embedded-utxo-fold-expect) AND the fold is live at the tip; + // every failure path reverts to today's fee-unknown exclusion. + // The #1218 gbt-xcheck-txmerkle guard is UNTOUCHED and stays + // the referee on any divergence. + const std::string& embedded_utxo_fold_fees_db = std::string(), + const std::string& embedded_utxo_fold_expect = std::string()) { namespace io = boost::asio; @@ -2130,6 +2150,12 @@ int run_node(bool testnet, const std::string& rpc_endpoint, // bundle's Mempool (set_utxo), so the lane must outlive the mempool that // references it (reverse destruction order at scope exit). dash::coin::UtxoLane utxo_lane; + // W5-A fold fee source: SAME outlive-the-mempool rule as utxo_lane above + // (the mempool's m_fee_coin_lookup lambda points into fold_fee_source, + // which reads fold_fee_fold). Declared before node_coin_state, destroyed + // after it. Constructed only under --embedded-utxo-fold-fees (below). + std::unique_ptr fold_fee_fold; + std::unique_ptr fold_fee_source; dash::coin::NodeCoinState node_coin_state; // ── Mempool-tx serving switch (--embedded-serve-mempool-txs) ──────────── @@ -2416,6 +2442,76 @@ int run_node(bool testnet, const std::string& rpc_endpoint, } } + // ── W5-A: graduated-fold fee pricing (--embedded-utxo-fold-fees) ──────── + // DEFAULT OFF. Constructs the W3 full-history UTXO fold + FoldFeeSource + // and installs it as the mempool's THIRD coin source (fee pricing + + // selection vin resolution — fold_fee_source.hpp carries the full trust + // model). Fail-closed at every step: any construction failure leaves the + // mempool exactly as today (fee-unknown exclusion), never a fatal. + // (fold_fee_fold / fold_fee_source declared next to utxo_lane above — + // the outlive-the-mempool rule.) + std::shared_ptr fold_fee_tip_sub; + if (!embedded_utxo_fold_fees_db.empty()) { + if (embedded_utxo_fold_expect.empty()) { + std::cout << "[run] --embedded-utxo-fold-fees given WITHOUT" + " --embedded-utxo-fold-expect — the operator must" + " restate the anchor hash; fold fee pricing NOT" + " armed (fee-unknown exclusion unchanged)\n"; + } else { + dash::coin::replay::ReplayUtxoFoldOptions fopts; + fopts.check_prev_linkage = true; // reorg tripwire (ChainLocks + // make real reorgs ~nonexistent; + // a trip disarms until restart) + fold_fee_fold = std::make_unique< + dash::coin::replay::ReplayUtxoFold>(fopts); + if (!fold_fee_fold->open(embedded_utxo_fold_fees_db)) { + std::cout << "[run] --embedded-utxo-fold-fees: fold store " + << embedded_utxo_fold_fees_db << " FAILED to open" + " (see log) — fold fee pricing NOT armed\n"; + fold_fee_fold.reset(); + } else { + dash::coin::FoldFeeSource::Options sopts; + sopts.operator_expect = embedded_utxo_fold_expect; + fold_fee_source = std::make_unique( + *fold_fee_fold, sopts); + node_coin_state.mempool().set_fee_coin_lookup( + [fs = fold_fee_source.get()]( + const ::core::coin::Outpoint& op, + ::core::coin::Coin& out) { + return fs->lookup(op, out); + }); + // Live lane: every connected block both advances the trust + // tip AND (when contiguous with the fold cursor — e.g. after + // the replay lane bridged to the tip, or across a short + // restart covered by the 288-block bootstrap window) feeds + // the fold. Idempotent acks + tolerated gaps + the prev-hash + // tripwire live inside feed(). + fold_fee_tip_sub = coin_state.block_connected.subscribe( + [fs = fold_fee_source.get()]( + const dash::interfaces::BlockConnected& bc) { + auto packed_hdr = ::pack( + static_cast( + bc.block)); + const uint256 bh = ::dash::crypto::hash_x11( + packed_hdr.get_span()); + fs->note_tip(bc.height); + fs->feed(bc.height, bh, bc.block); + }); + std::cout << "[run] W5-A FOLD FEE SOURCE armed (DARK until" + " graduated): db=" << embedded_utxo_fold_fees_db + << " fold_h=" << fold_fee_source->fold_height() + << " graduated=" + << (fold_fee_source->graduated() ? "YES" : "no") + << " anchor_h=" + << fold_fee_fold->gate_anchor_height() + << " — pricing activates ONLY after" + " hash_serialized_2 == the restated anchor AND" + " the fold is live at the tip; every failure" + " path reverts to the fee-unknown exclusion\n"; + } + } + } + // REQUIRED always-reachable dashd-RPC fallback arm -- the safety + // [GBT-XCHECK] cross-check path, NEVER removed (operator standing rule). // Wired to NodeRPC::getwork() (dashd getblocktemplate -> rich DashWorkData, @@ -3427,6 +3523,12 @@ int run_node(bool testnet, const std::string& rpc_endpoint, // W5 integration: the fold engine the lane drives, and its consumer. std::unique_ptr replay_fold_engine; std::unique_ptr replay_fold_consumer; + // W5-A: the UTXO-fold side consumer riding the SAME bulk lane (Tee) when + // --embedded-utxo-fold-fees is armed together with --replay-bulk. A fold + // refusal (incl. a graduation-gate FAIL) stops the lane with the fold's + // own named cause — never a silent skip past divergent state. + std::unique_ptr replay_utxo_side; + std::unique_ptr replay_utxo_tee; // THE SEAM: W4's quorum lane and the bridge that closes the loop. std::unique_ptr replay_quorum_engine; std::unique_ptr replay_quorum_bridge; @@ -6429,6 +6531,33 @@ int run_node(bool testnet, const std::string& rpc_endpoint, "index NOT armed\n"; } + // ── W5-A: UTXO fold rides the SAME bulk lane (side of a Tee) ── + // Catch-up (genesis → anchor → tip) AND steady-state tip-follow: + // every verified in-order body is handed to FoldFeeSource::feed + // BEFORE drain_buffer() prunes it. The graduation gate fires + // inside feed() exactly as the cursor crosses the anchor. + if (fold_fee_source) { + struct UtxoFoldSide : rp::IReplayBlockConsumer { + dash::coin::FoldFeeSource* fs; + explicit UtxoFoldSide(dash::coin::FoldFeeSource* f) + : fs(f) {} + bool on_replay_block(uint32_t h, const uint256& hash, + const dash::coin::BlockType& b) + override + { + return fs->feed(h, hash, b); + } + }; + replay_utxo_side = std::make_unique( + fold_fee_source.get()); + replay_utxo_tee = std::make_unique( + replay_consumer, replay_utxo_side.get()); + replay_consumer = replay_utxo_tee.get(); + std::cout << "[run] W5-A: UTXO fold FEEDS from the bulk lane" + " (Tee side; fold cursor h=" + << fold_fee_source->fold_height() << ")\n"; + } + if (!g_replay_bulk_capture_dir.empty()) { replay_capture = std::make_unique( g_replay_bulk_capture_dir, replay_consumer); @@ -6537,6 +6666,26 @@ int run_node(bool testnet, const std::string& rpc_endpoint, // every race — it simply no longer wins a band it never enters. // Unarmed (--replay-bulk alone) the default 12 is untouched. if (replay_fold_consumer) rcfg.tip_exclusion = 0; + // ── W5-A start-height sentinel (the named trap) ─────────────── + // A COLD UTXO fold must start at GENESIS: pre-DIP3 coins are + // still spendable, so a DIP3-anchored fold can NEVER match the + // graduation anchor — and `--replay-bulk-start 0` silently means + // "network default = DIP3 1,028,160". Refuse NOW rather than + // GATE FAIL fifteen hours of download later. (A fold with a + // cursor resumes wherever it stands; the lane's own cursor + + // the fold's idempotent acks absorb any overlap.) + if (fold_fee_source && fold_fee_fold + && !fold_fee_fold->have_cursor() && rcfg.start_height > 1) { + std::cerr << "[run] FATAL: --embedded-utxo-fold-fees on an" + " EMPTY fold store requires --replay-bulk-start 1" + " (genesis): configured start h=" + << rcfg.start_height + << " can never reach the graduation anchor\n"; + return 1; + } + // The fold must BE current to price live-tip fees: close the + // 12-block tip-exclusion band exactly as the DML fold does. + if (fold_fee_source) rcfg.tip_exclusion = 0; replay_lane = std::make_unique( std::move(seams), rcfg, replay_backfill.get(), replay_consumer, replay_cursor.get()); @@ -7885,6 +8034,12 @@ int main(int argc, char** argv) std::string replay_utxo_db; // --replay-utxo-db PATH (fold store) bool replay_utxo_hash = false; // --replay-utxo-hash: compute hash_serialized_2 std::string replay_utxo_expect; // --replay-utxo-expect HEX (gate compare, exit code) + // ── W5-A: graduated-fold fee pricing (fold_fee_source.hpp) ──────────── + // DEFAULT OFF (empty). Both must be given together; --embedded-utxo-fold- + // expect is the operator's RESTATEMENT of the anchor hash (a stale or + // foreign DB must not graduate on its own say-so). + std::string embedded_utxo_fold_fees_db; // --embedded-utxo-fold-fees PATH + std::string embedded_utxo_fold_expect; // --embedded-utxo-fold-expect HEX for (int i = 1; i < argc; ++i) { if (std::strcmp(argv[i], "--version") == 0) { std::cout << "c2pool-dash " << C2POOL_VERSION << "\n"; @@ -7967,6 +8122,10 @@ int main(int argc, char** argv) pin_splice_xcheck_arm = true; else if (std::strcmp(argv[i], "--pin-splice-block-budget") == 0) pin_splice_block_budget = true; + else if (std::strcmp(argv[i], "--embedded-utxo-fold-fees") == 0 && i + 1 < argc) + embedded_utxo_fold_fees_db = argv[++i]; // W5-A fold fee source + else if (std::strcmp(argv[i], "--embedded-utxo-fold-expect") == 0 && i + 1 < argc) + embedded_utxo_fold_expect = argv[++i]; // W5-A anchor restatement else if (std::strcmp(argv[i], "--replay-utxo-db") == 0 && i + 1 < argc) replay_utxo_db = argv[++i]; else if (std::strcmp(argv[i], "--replay-utxo-hash") == 0) @@ -8255,7 +8414,9 @@ int main(int argc, char** argv) embedded_accrue_asset_locks, // #107 PHASE 2 embedded_null_arm, // #127 embedded_accrue_asset_unlocks, // #143 Variant B - embedded_ingest_isdlock); // G4 isdlock feed + embedded_ingest_isdlock, // G4 isdlock feed + embedded_utxo_fold_fees_db, // W5-A fold fee source + embedded_utxo_fold_expect); // W5-A anchor restatement } return run_selftest(); } \ No newline at end of file diff --git a/src/impl/dash/coin/fold_fee_source.hpp b/src/impl/dash/coin/fold_fee_source.hpp new file mode 100644 index 000000000..ebc684656 --- /dev/null +++ b/src/impl/dash/coin/fold_fee_source.hpp @@ -0,0 +1,328 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +#pragma once + +// W5-A: FoldFeeSource — the adapter that lets the full-history UTXO fold +// (replay_utxo_fold.hpp, W3) price mempool-tx inputs the forward-built +// UTXOViewCache cannot know (coins created before the node's replay horizon), +// closing the DOMINANT dashd-only tx class behind the gbt-xcheck tx-merkle +// mismatches (20/34 across the 26-event diagnosis: ordinary fee-payers +// spending OLD confirmed coins → fee_known=false → excluded → embedded is a +// strict SUBSET of dashd → guard swaps to the dashd template). +// +// ── Trust model (reward-critical; every branch fails TOWARD EXCLUSION) ───── +// +// A fold answer may reach a fee computation ONLY while ALL of these hold: +// +// 1. GRADUATED: the fold proved byte-equality with dashd's own +// `gettxoutsetinfo hash_serialized_2` at the pinned anchor +// (ReplayUtxoFold::try_graduate → the durable 'G' record), AND the +// operator RESTATED the expected anchor hash on the command line +// (--embedded-utxo-fold-fees-expect) — a stale or foreign DB cannot +// graduate on its own say-so. A wrong fee can OVERSTATE coinbasevalue = +// an invalid found block = a lost block, so an unverified fold is +// arithmetically unreachable from the fee path (the latch is the FIRST +// statement of lookup()). +// 2. LIVE-CONTIGUOUS: the fold's cursor is within `tip_lag_max` (default 2) +// of the last tip height this adapter was shown (note_tip). A fold still +// catching up prices nothing — it could hold coins the chain has since +// spent, admitting a tx dashd already dropped (superset ⇒ guard trip). +// 3. UNPOISONED / UNDISARMED: any fold refusal on the feed path (gap, +// prev-hash linkage break = reorg tripwire, gate fail, store error) +// latches the disarm. The latch clears ONLY by process restart — with +// ChainLocks live, mainnet DASH reorgs are ~nonexistent, so the latch is +// a rare-case backstop, not an availability cost. +// +// Even a WRONG-but-graduated answer is bounded: selection divergence is +// caught by the #1218 gbt-xcheck-txmerkle guard (serving refused / swapped to +// the dashd arm where present — never an invalid block from divergence +// alone); the graduation gate exists because fee VALUES additionally flow +// into coinbasevalue, which the tx-merkle guard does not cover. +// +// ── Threading ────────────────────────────────────────────────────────────── +// ReplayUtxoFold is single-thread by design (plain unordered_map overlay). +// This adapter owns the mutex: feed()/disconnect()/note_tip() run on the io +// thread; lookup() runs under Mempool::m_mutex from intake / recompute / +// selection. Lock order on the money path: Mempool::m_mutex → +// FoldFeeSource::m_mutex. The feed path takes m_mutex alone and never calls +// into Mempool — no cycle. The LevelDB point-get under the mempool lock is +// the same cost class as the existing UTXOViewCache base read. +// +// Constructed ONLY by the --embedded-utxo-fold-fees flag family in +// main_dash.cpp (DASH lane only). Absent the flag, nothing here exists and +// Mempool::m_fee_coin_lookup stays empty — production byte-identical. + +#include "replay_utxo_fold.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +namespace dash { +namespace coin { + +class FoldFeeSource { +public: + struct Options { + // The operator's restatement of the anchor hash (hex, REQUIRED by the + // wiring). Compared against the STORED graduation record AND against + // the fold's compiled/injected gate_anchor_expect. + std::string operator_expect; + // Trust requires fold.best_height + tip_lag_max >= last noted tip. + uint32_t tip_lag_max = 2; + }; + + /// `fold` must already be open()ed; not owned. The constructor attempts a + /// TRUST RESTORE from the stored 'G' record (a restart with the cursor + /// past the anchor can regain trust ONLY from a recorded PASS), and — the + /// crash-window case — re-runs the gate when the cursor is parked exactly + /// on the anchor with no record yet. + FoldFeeSource(replay::ReplayUtxoFold& fold, Options opts) + : m_fold(fold), m_opts(std::move(opts)) + { + normalize_hex(m_opts.operator_expect); + std::lock_guard lk(m_mutex); + if (m_fold.have_cursor() + && m_fold.best_height() == gate_anchor_height_locked() + && !m_fold.graduation().has_value()) { + // Parked on the anchor, ungraduated (fresh arrival or a crash + // between the PASS and the 'G' put): run the gate now — pure + // re-measure, same verdict. + try_graduate_locked(); + } else { + restore_graduation_locked(); + } + } + FoldFeeSource(const FoldFeeSource&) = delete; + FoldFeeSource& operator=(const FoldFeeSource&) = delete; + + // ── Feed side (io thread) ────────────────────────────────────────────── + + /// Hand one block to the fold. Used by BOTH lanes: + /// * the W2 bulk-replay lane (strictly in-order from the fold's own + /// cursor — catch-up AND steady-state tip-follow with + /// tip_exclusion=0); + /// * any opportunistic caller (idempotent ack below cursor, tolerated + /// gap above it — a gap only marks the fold behind, it is not an + /// error while another lane is still bridging). + /// Returns false ONLY on a fold refusal (which also disarms trust) — the + /// bulk lane then stops with the fold's own named cause. + bool feed(uint32_t height, const uint256& hash, const BlockType& block) + { + std::lock_guard lk(m_mutex); + if (!m_fold.is_open()) return fail_locked("fold-not-open"); + if (m_fold.have_cursor() && height <= m_fold.best_height()) + return true; // idempotent redelivery ack + // Above the fold's next expected height — including a LIVE TIP block + // landing on an EMPTY store (cold start must begin at genesis, and + // only the replay lane starts there). NOT a refusal: the replay lane + // is (or will be) bridging the gap, and trust demands tip proximity, + // so a genuinely-behind fold prices nothing meanwhile. + const uint32_t next = + m_fold.have_cursor() ? m_fold.resume_height() : 1; + if (height > next) { + if (!m_gap_noted) { + m_gap_noted = true; + LOG_INFO << "[FOLD-FEE] feed gap: got h=" << height + << " fold wants h=" << next + << " — fold behind, fee-pricing stays dark until the" + " replay lane bridges to the tip"; + } + return true; + } + if (!m_fold.on_replay_block(height, hash, block)) + return fail_locked(m_fold.refusal()); + m_gap_noted = false; + // THE GATE fires exactly as the cursor crosses the anchor. + if (m_fold.best_height() == gate_anchor_height_locked() + && !m_graduated.load(std::memory_order_relaxed)) { + if (!try_graduate_locked() && m_fold.poisoned()) + return false; // gate FAIL: poisoned + disarmed, stop the lane + } + recompute_trust_locked(); + return true; + } + + /// Reorg support within the fold's undo window. Any failure disarms. + bool disconnect(const BlockType& tip_block) + { + std::lock_guard lk(m_mutex); + if (!m_fold.disconnect_tip(tip_block)) + return fail_locked(m_fold.refusal()); + recompute_trust_locked(); + return true; + } + + /// The chain-tip observation trust condition 2 compares against. Cheap; + /// call per connected block / header tip advance. + void note_tip(uint32_t tip_height) + { + std::lock_guard lk(m_mutex); + if (tip_height > m_tip_height) m_tip_height = tip_height; + recompute_trust_locked(); + } + + // ── Lookup side (called under Mempool::m_mutex) ──────────────────────── + + /// The Mempool::set_fee_coin_lookup target. Answers ONLY while trusted; + /// missing, spent and immature-coinbase outpoints all answer false + /// (fail-toward-exclusion — dashd would not pool an immature-coinbase + /// spend, and admitting one would make embedded a SUPERSET ⇒ guard trip). + bool lookup(const ::core::coin::Outpoint& op, ::core::coin::Coin& out) const + { + if (!m_trusted.load(std::memory_order_acquire)) return false; // THE LATCH + std::lock_guard lk(m_mutex); + ::core::coin::Coin c; + if (!m_fold.get_coin(op, c)) return false; // missing OR spent + if (c.coinbase + && !replay::ReplayUtxoFold::is_mature(c, m_fold.best_height() + 1)) + return false; + out = c; + return true; + } + + // ── Introspection (wiring + soak logs) ───────────────────────────────── + bool graduated() const { return m_graduated.load(std::memory_order_relaxed); } + bool trusted() const { return m_trusted.load(std::memory_order_relaxed); } + bool disarmed() const { return m_disarmed.load(std::memory_order_relaxed); } + std::string disarm_cause() const + { + std::lock_guard lk(m_mutex); + return m_disarm_cause; + } + uint32_t fold_height() const + { + std::lock_guard lk(m_mutex); + return m_fold.have_cursor() ? m_fold.best_height() : 0; + } + +private: + static void normalize_hex(std::string& s) + { + for (auto& c : s) + c = static_cast(std::tolower(static_cast(c))); + } + + uint32_t gate_anchor_height_locked() const + { + // The anchor the FOLD was constructed with (KAT-injectable there); + // the adapter never carries a second copy that could drift. + return m_fold.gate_anchor_height(); + } + + /// Disarm latch: named once, cleared only by restart. + bool fail_locked(const std::string& why) + { + if (!m_disarmed.exchange(true, std::memory_order_relaxed)) { + m_disarm_cause = why; + LOG_ERROR << "[FOLD-FEE] DISARMED (until restart): " << why + << " — fee pricing reverts to the fee-unknown exclusion"; + } + m_trusted.store(false, std::memory_order_release); + return false; + } + + /// Trust restore from the stored 'G' record (restart with cursor past the + /// anchor). All four equalities required; any miss = never graduated. + void restore_graduation_locked() + { + auto g = m_fold.graduation(); + if (!g) { + if (m_fold.have_cursor() + && m_fold.best_height() > gate_anchor_height_locked()) { + LOG_WARNING << "[FOLD-FEE] store cursor h=" + << m_fold.best_height() + << " is PAST the anchor h=" + << gate_anchor_height_locked() + << " with NO graduation record — this store can" + " never graduate (re-fold, or supply a fresh" + " operator anchor)"; + } + return; + } + if (g->anchor_height != gate_anchor_height_locked()) { + LOG_WARNING << "[FOLD-FEE] stored graduation anchor h=" + << g->anchor_height << " != configured h=" + << gate_anchor_height_locked() << " — IGNORED"; + return; + } + const std::string measured = g->measured_hash.GetHex(); + if (measured != g->expected_hash.GetHex() + || measured != m_opts.operator_expect) { + LOG_WARNING << "[FOLD-FEE] stored graduation hash " << measured + << " does not match the operator-restated expect " + << m_opts.operator_expect << " — IGNORED (a foreign" + " or stale DB cannot graduate on its own say-so)"; + return; + } + m_graduated.store(true, std::memory_order_relaxed); + LOG_INFO << "[FOLD-FEE] graduation RESTORED from record: anchor h=" + << g->anchor_height << " hash=" << measured + << " coins=" << g->coins; + recompute_trust_locked(); + } + + /// Run the gate (cursor must stand on the anchor). PASS → graduated=true; + /// hash-mismatch FAIL → fold poisoned → disarm. + bool try_graduate_locked() + { + // The operator's restatement must agree with the fold's own expected + // anchor BEFORE we measure — refusing early keeps a mis-typed flag + // from burning a multi-minute set scan and then "failing" the fold. + if (m_opts.operator_expect != lowercase(m_fold.gate_anchor_expect())) { + fail_locked("operator-expect mismatch: '" + m_opts.operator_expect + + "' vs fold anchor '" + m_fold.gate_anchor_expect() + "'"); + return false; + } + if (m_fold.try_graduate()) { + m_graduated.store(true, std::memory_order_relaxed); + recompute_trust_locked(); + return true; + } + if (m_fold.poisoned()) fail_locked(m_fold.refusal()); + return false; + } + + static std::string lowercase(std::string s) { normalize_hex(s); return s; } + + void recompute_trust_locked() + { + const bool t = m_graduated.load(std::memory_order_relaxed) + && !m_disarmed.load(std::memory_order_relaxed) + && !m_fold.poisoned() + && m_fold.have_cursor() + && m_tip_height > 0 + && m_fold.best_height() + m_opts.tip_lag_max >= m_tip_height; + const bool was = m_trusted.exchange(t, std::memory_order_release); + if (t && !was) { + LOG_INFO << "[FOLD-FEE] TRUSTED: graduated fold is live at h=" + << m_fold.best_height() << " (tip h=" << m_tip_height + << ") — full-history fee pricing ACTIVE (backlog resolves" + " on the next block-connect recompute)"; + } else if (!t && was) { + LOG_WARNING << "[FOLD-FEE] trust dropped: fold h=" + << (m_fold.have_cursor() ? m_fold.best_height() : 0) + << " tip h=" << m_tip_height + << (m_disarmed.load(std::memory_order_relaxed) + ? " (disarmed: " + m_disarm_cause + ")" + : " (lag)"); + } + } + + replay::ReplayUtxoFold& m_fold; // not owned; single-thread core + Options m_opts; + mutable std::mutex m_mutex; + std::atomic m_graduated{false}; + std::atomic m_trusted{false}; // the ONLY bit lookup() consults first + std::atomic m_disarmed{false}; + std::string m_disarm_cause; + uint32_t m_tip_height{0}; + bool m_gap_noted{false}; +}; + +} // namespace coin +} // namespace dash diff --git a/src/impl/dash/coin/mempool.hpp b/src/impl/dash/coin/mempool.hpp index 90ed086d5..1d1779c8b 100644 --- a/src/impl/dash/coin/mempool.hpp +++ b/src/impl/dash/coin/mempool.hpp @@ -307,6 +307,28 @@ class Mempool { } bool has_external_coin_lookup() const { return static_cast(m_external_coin_lookup); } + /// THIRD SOURCE for FEE PRICING + selection vin resolution (W5-A) — the + /// graduated full-history UTXO fold (fold_fee_source.hpp). DELIBERATELY a + /// separate slot from m_external_coin_lookup above: that slot is the + /// dashd-RPC arm feeding ONLY pinned_tx_admissible, and routing the whole + /// mempool's fee pricing through it would be an unflagged production + /// behaviour change on every node with a pin armed. This slot is + /// consulted ONLY when (a) the forward-built UTXOViewCache misses AND + /// (b) the in-pool CPFP parent misses — i.e. precisely the "coin older + /// than the replay horizon" class — and the installed fn (FoldFeeSource:: + /// lookup) itself answers nothing until the fold has GRADUATED + /// (hash_serialized_2 byte-equal to dashd at the pinned anchor) and is + /// live at the tip. Unset (every configuration without + /// --embedded-utxo-fold-fees) = byte-identical behaviour. + /// Set once at wiring time, before any template build, never mutated + /// afterwards (same discipline as m_external_coin_lookup). + void set_fee_coin_lookup( + std::function fn) + { + m_fee_coin_lookup = std::move(fn); + } + bool has_fee_coin_lookup() const { return static_cast(m_fee_coin_lookup); } + PinnedTxGate pinned_tx_admissible(const MutableTransaction& tx, uint32_t next_height) const { // SIZE FIRST. It is the cheapest check and the only one whose failure @@ -1116,8 +1138,22 @@ class Mempool { ::core::coin::Outpoint op(vin.prevout.hash, vin.prevout.index); ::core::coin::Coin coin; - if (!utxo->get_coin(op, coin)) { - // Stale input: neither selected-parent nor UTXO. + bool resolved = utxo->get_coin(op, coin); + // FOLD THIRD SOURCE (W5-A): a tx whose fee the fold + // priced spends a coin the forward view CANNOT hold — + // without this arm the stale-input guard would drop + // at selection exactly what pricing just admitted. + // Same precedence as compute_fee_locked (view first); + // the installed fn answers nothing until graduated + + // live, so unset/ungraduated keeps this branch + // byte-identical to the pre-W5 guard. + if (!resolved && m_fee_coin_lookup) { + resolved = m_fee_coin_lookup(op, coin) + && !coin.is_spent(); + } + if (!resolved) { + // Stale input: neither selected-parent, UTXO, + // nor (graduated) fold. ok = false; break; } @@ -1285,6 +1321,13 @@ class Mempool { // path where m_utxo is already read lock-free. std::function m_external_coin_lookup; + // Third-source coin lookup for fee pricing + selection vin resolution + // (W5-A graduated UTXO fold; see set_fee_coin_lookup). Same set-once + // discipline. Consulted under m_mutex; the installed fn takes its own + // mutex AFTER ours (lock order Mempool::m_mutex → FoldFeeSource::m_mutex, + // no cycle — the fold's feed path never calls into Mempool). + std::function + m_fee_coin_lookup; /// G1: gather `txid` plus every UNSELECTED in-mempool ancestor into /// `out`, parents strictly before children (post-order DFS). `seen` @@ -1584,6 +1627,24 @@ class Mempool { pit->second.tx.vout[vin.prevout.index].value); continue; } + // FOLD THIRD SOURCE (W5-A): full-history UTXO fold, consulted + // ONLY on the double miss above — the "coin older than the + // replay horizon" class. Order is load-bearing: the forward + // UTXOViewCache is the only source that knows RECENT + // spends/creates and must win; in-pool parents cover the + // unconfirmed; the fold covers deep history. Coin value/height/ + // coinbase are immutable facts, so a fold answer for a coin it + // holds is historically correct regardless of cursor lag; the + // installed fn (FoldFeeSource::lookup) additionally answers + // NOTHING until graduated + live (fail-toward-exclusion). + if (m_fee_coin_lookup) { + ::core::coin::Coin fold_coin; + if (m_fee_coin_lookup(op, fold_coin) + && !fold_coin.is_spent()) { + in_sum += static_cast(fold_coin.value); + continue; + } + } entry.fee_known = false; entry.fee = 0; return false; diff --git a/src/impl/dash/coin/replay_utxo_fold.hpp b/src/impl/dash/coin/replay_utxo_fold.hpp index 05eb59041..d79aa562c 100644 --- a/src/impl/dash/coin/replay_utxo_fold.hpp +++ b/src/impl/dash/coin/replay_utxo_fold.hpp @@ -83,14 +83,28 @@ // feed any serving decision (and nothing constructs this class outside the // --replay-utxo-* utility surface — the serve path is byte-identical). // -// ── Persistence layout (format version 1, fail-loud on mismatch) ─────────── +// ── Persistence layout (format version 2, fail-loud on mismatch) ─────────── // -// 'V' → uint32 LE format version (1) +// 'V' → uint32 LE format version (2) // 'B' → cursor: best_hash(32) + best_height(4 LE) + undo_floor(4 LE) // 'C' + txid(32) + vout(4 LE) → serialized Coin (core::coin::serialize_coin) // 'U' + height(4 BE) → serialized BlockUndo (last `undo_window` blocks) // 'H' + height(4 BE) → block hash(32) (cursor restore on disconnect; // same retention window as 'U') +// 'G' → GRADUATION record (W5-A): written ONLY by a PASSING +// try_graduate() at the anchor cursor. Absence IS the +// ungraduated state — a store whose cursor is past the +// anchor with no 'G' record can NEVER graduate (re-fold, +// or gate against a fresh operator-supplied anchor). +// Layout: format u32 LE | anchor_height u32 LE | +// anchor_block_hash(32) | expected_hash(32) | +// measured_hash(32) | coins u64 LE | tx_groups u64 LE | +// unix_time i64 LE. +// +// Version history: v1 = the pre-graduation layout (no 'G' key class; no +// production store was ever built at v1 — the fold had no feeder until W5-A). +// v2 adds 'G'. Any layout change bumps the version so an old binary fails +// LOUD instead of silently misreading state. // // All per-block mutations accumulate in a write-back overlay and are // committed in ONE WriteBatch per flush (default every 2000 blocks) together @@ -113,8 +127,10 @@ #include #include +#include #include #include +#include #include #include #include @@ -138,8 +154,22 @@ static constexpr uint32_t REPLAY_UTXO_UNDO_WINDOW = 100; // On-disk format version ('V' key). Bump on ANY layout change so an old // binary fails LOUD instead of silently misreading state (standing versioned- -// format trap, DASH_FULL_HISTORY_REPLAY_MODE.md §4.5). -static constexpr uint32_t REPLAY_UTXO_FORMAT_VERSION = 1; +// format trap, DASH_FULL_HISTORY_REPLAY_MODE.md §4.5). v2: 'G' graduation +// record (W5-A) — see the layout table above. +static constexpr uint32_t REPLAY_UTXO_FORMAT_VERSION = 2; + +// ── THE GRADUATION ANCHOR (W5-A reward-safety gate) ───────────────────────── +// The live-mainnet anchor the fold must prove byte-equality against before +// its answers may EVER price a mempool fee (see the module header's GATE +// section): dashd `gettxoutsetinfo hash_serialized_2` at h=2,516,758. +// KAT-injectable via ReplayUtxoFoldOptions so synthetic chains can gate at a +// tiny anchor; production wiring (--embedded-utxo-fold-fees) keeps these +// defaults and ADDITIONALLY requires the operator to restate the expected +// hash on the command line (a stale/foreign DB must not graduate on its own +// say-so). +static constexpr uint32_t REPLAY_UTXO_GATE_ANCHOR_HEIGHT = 2'516'758; +inline constexpr const char* REPLAY_UTXO_GATE_ANCHOR_EXPECT = + "3d14913768a9d492bfa7a42fe9b111cff625b80e35bb4133e1d60cf3991c2319"; // ── dashd serialize.h primitives (exact transcriptions) ──────────────────── @@ -238,6 +268,30 @@ struct ReplayUtxoFoldOptions { uint32_t flush_interval = 2000; // blocks per WriteBatch (≈ W2 work-ahead) size_t write_buffer_size = 32 * 1024 * 1024; size_t block_cache_size = 64 * 1024 * 1024; + // ── W5-A graduation gate (KAT-injectable; ships the live anchor) ────── + uint32_t gate_anchor_height = REPLAY_UTXO_GATE_ANCHOR_HEIGHT; + std::string gate_anchor_expect = REPLAY_UTXO_GATE_ANCHOR_EXPECT; + // Prev-hash linkage check (W5-A live-feed safety): when true, a block at + // best+1 whose m_previous_block does not equal the cursor's best_hash is + // REFUSED — the structural tripwire that makes silently folding across a + // reorg (orphaned tip still in the set) impossible; the adapter disarms + // fee-pricing on that refusal. Default OFF so pre-existing synthetic- + // chain KATs (which use synthetic hashes, not linked headers) are byte- + // identical; FoldFeeSource always arms it. + bool check_prev_linkage = false; +}; + +/// The 'G' graduation record (see layout table). Only ever written by a +/// PASSING gate; absence IS the ungraduated state. +struct ReplayUtxoGraduation { + uint32_t format{0}; + uint32_t anchor_height{0}; + uint256 anchor_block_hash; // the cursor's best_hash at the anchor + uint256 expected_hash; // what the gate compared against + uint256 measured_hash; // hash_serialized_2 actually measured (== expected) + uint64_t coins{0}; + uint64_t tx_groups{0}; + int64_t unix_time{0}; }; struct ReplayUtxoSetHash { @@ -316,6 +370,11 @@ class ReplayUtxoFold { uint32_t best_height() const { return m_best_height; } uint256 best_hash() const { return m_best_hash; } uint32_t undo_window() const { return m_opts.undo_window; } + uint32_t gate_anchor_height() const { return m_opts.gate_anchor_height; } + const std::string& gate_anchor_expect() const + { + return m_opts.gate_anchor_expect; + } /// Next height the fold expects. Empty store: 0 (genesis pin) — though /// delivering height 1 first is equally accepted (dashd never folds @@ -332,6 +391,11 @@ class ReplayUtxoFold { const BlockType& block) { if (!m_store) return refuse("replay-utxo-not-open"); + // A failed graduation gate poisons the fold (W1 self-check idiom): a + // fold rule that diverged from dashd must not keep extending state. + if (m_poisoned) + return refuse("replay-utxo-poisoned (gate FAIL latched): " + + m_poison_cause); // Idempotent redelivery (resume overlap): already folded, ack. if (m_have_cursor && height <= m_best_height) @@ -341,6 +405,19 @@ class ReplayUtxoFold { if (m_have_cursor && height != m_best_height + 1) return refuse("replay-utxo-gap: got h=" + std::to_string(height) + " want h=" + std::to_string(m_best_height + 1)); + + // W5-A prev-hash linkage (opt-in; see ReplayUtxoFoldOptions): the + // structural reorg tripwire. Folding new-chain h+1 on top of an + // ORPHANED h would silently corrupt coin state (a wrong value can + // OVERSTATE a fee → overstated coinbasevalue → invalid found block), + // so a broken link is refused and the adapter disarms fee-pricing. + if (m_opts.check_prev_linkage && m_have_cursor && m_best_height >= 1 + && !(block.m_previous_block == m_best_hash)) + return refuse("replay-utxo-prev-mismatch: h=" + + std::to_string(height) + " prev=" + + block.m_previous_block.GetHex().substr(0, 16) + + " cursor=" + m_best_hash.GetHex().substr(0, 16) + + " (reorg or mis-fed lane)"); // Cold start must begin at the chain's origin — a mid-chain UTXO // fold can never reach the gate hash (unlike the DIP3-anchored MN // fold). Height 0 or 1 both establish the cursor correctly. @@ -597,6 +674,105 @@ class ReplayUtxoFold { return out; } + // ── W5-A GRADUATION GATE ─────────────────────────────────────────────── + // The single place fold state can ever EARN fee-pricing trust. Callable + // ONLY while the cursor stands exactly on the anchor height (hash_ + // serialized_2 hashes the CURRENT set, so any other cursor is a category + // error, refused). PASS → the 'G' record is written (the durable proof a + // restart may restore trust from); FAIL → the fold is POISONED (latched: + // every further on_replay_block refuses) and NOTHING is written — absence + // of 'G' is the fail state. A crash between the pass and the 'G' put + // leaves an ungraduated store parked at the anchor; the adapter re-runs + // the gate at construction in that case (same verdict, pure re-measure). + + bool poisoned() const { return m_poisoned; } + + /// Read the stored graduation record ('G'). nullopt = never graduated. + std::optional graduation() + { + if (!m_store) return std::nullopt; + std::vector v; + if (!m_store->get(key_graduation(), v)) return std::nullopt; + if (v.size() != 4 + 4 + 32 + 32 + 32 + 8 + 8 + 8) { + refuse("replay-utxo-graduation-record-corrupt: " + + std::to_string(v.size()) + " bytes"); + return std::nullopt; + } + ReplayUtxoGraduation g; + size_t at = 0; + g.format = le32_at(v, at); at += 4; + g.anchor_height = le32_at(v, at); at += 4; + std::memcpy(g.anchor_block_hash.data(), v.data() + at, 32); at += 32; + std::memcpy(g.expected_hash.data(), v.data() + at, 32); at += 32; + std::memcpy(g.measured_hash.data(), v.data() + at, 32); at += 32; + g.coins = le64_at(v, at); at += 8; + g.tx_groups = le64_at(v, at); at += 8; + g.unix_time = static_cast(le64_at(v, at)); + return g; + } + + /// Run the gate at the anchor cursor. Returns true ONLY on a byte-equal + /// PASS (record written). Any failure is named via refusal(); a HASH + /// MISMATCH additionally poisons the fold. + bool try_graduate() + { + if (!m_store) return refuse("replay-utxo-not-open"); + if (m_poisoned) + return refuse("replay-utxo-poisoned: " + m_poison_cause); + if (!m_have_cursor || m_best_height != m_opts.gate_anchor_height) + return refuse( + "replay-utxo-gate-wrong-cursor: h=" + + std::to_string(m_have_cursor ? m_best_height : 0) + + " anchor=" + std::to_string(m_opts.gate_anchor_height) + + " (hash_serialized_2 hashes the CURRENT set — verification" + " is only possible standing exactly on the anchor)"); + std::string want_hex = m_opts.gate_anchor_expect; + for (auto& c : want_hex) + c = static_cast(std::tolower(static_cast(c))); + if (want_hex.size() != 64) + return refuse("replay-utxo-gate-bad-expect-hex: '" + + m_opts.gate_anchor_expect + "'"); + auto measured = hash_serialized_2(); // flushes first + if (!measured) return false; // refusal already named + if (measured->hash.GetHex() != want_hex) { + m_poisoned = true; + m_poison_cause = "gate-fail h=" + + std::to_string(m_opts.gate_anchor_height) + + " got=" + measured->hash.GetHex() + + " want=" + want_hex; + LOG_ERROR << "[REPLAY-UTXO] GATE FAIL h=" + << m_opts.gate_anchor_height + << " got=" << measured->hash.GetHex() + << " want=" << want_hex + << " coins=" << measured->coins + << " — fold POISONED (no further blocks accepted;" + " nothing written; fee-pricing stays disarmed)"; + return refuse("replay-utxo-" + m_poison_cause); + } + // PASS — write the durable record. expected == measured by + // definition on this (the only) writing path; both fields are kept + // so a reader can distinguish "what was demanded" from "what was + // seen" if the layout ever grows an operator-override arm. + std::vector rec; + rec.reserve(4 + 4 + 32 + 32 + 32 + 8 + 8 + 8); + append_le32(rec, REPLAY_UTXO_FORMAT_VERSION); + append_le32(rec, m_opts.gate_anchor_height); + rec.insert(rec.end(), m_best_hash.data(), m_best_hash.data() + 32); + rec.insert(rec.end(), measured->hash.data(), measured->hash.data() + 32); + rec.insert(rec.end(), measured->hash.data(), measured->hash.data() + 32); + append_le64(rec, measured->coins); + append_le64(rec, measured->tx_groups); + append_le64(rec, static_cast(std::time(nullptr))); + if (!m_store->put(key_graduation(), rec)) + return refuse("replay-utxo-gate-record-write-failed"); + LOG_INFO << "[REPLAY-UTXO] GATE PASS h=" << m_opts.gate_anchor_height + << " hash=" << measured->hash.GetHex() + << " coins=" << measured->coins + << " tx_groups=" << measured->tx_groups + << " — graduation record written"; + return true; + } + private: bool refuse(const std::string& why) { @@ -710,6 +886,7 @@ class ReplayUtxoFold { } static std::string key_undo(uint32_t h) { return key_be32('U', h); } static std::string key_hash(uint32_t h) { return key_be32('H', h); } + static std::string key_graduation() { return std::string(1, 'G'); } static uint32_t le32(const std::vector& v) { @@ -727,6 +904,21 @@ class ReplayUtxoFold { return uint32_t(v[at]) | (uint32_t(v[at + 1]) << 8) | (uint32_t(v[at + 2]) << 16) | (uint32_t(v[at + 3]) << 24); } + static uint64_t le64_at(const std::vector& v, size_t at) + { + return uint64_t(le32_at(v, at)) | + (uint64_t(le32_at(v, at + 4)) << 32); + } + static void append_le32(std::vector& v, uint32_t x) + { + for (int i = 0; i < 4; ++i) + v.push_back(static_cast((x >> (8 * i)) & 0xFF)); + } + static void append_le64(std::vector& v, uint64_t x) + { + for (int i = 0; i < 8; ++i) + v.push_back(static_cast((x >> (8 * i)) & 0xFF)); + } static void put_le32(std::vector& v, size_t at, uint32_t x) { v[at] = x & 0xFF; @@ -751,6 +943,10 @@ class ReplayUtxoFold { bool m_have_cursor{false}; uint32_t m_since_flush{0}; std::string m_refusal; + // W5-A gate poison latch (in-memory: a restart re-measures; the DURABLE + // fail state is the absence of 'G' past the anchor). + bool m_poisoned{false}; + std::string m_poison_cause; }; } // namespace replay diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 04426dada..19c41372d 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -558,7 +558,13 @@ if (BUILD_TESTING AND GTest_FOUND) # genesis short-circuit), resumable cursor across reopen, versioned-format # fail-loud, 100-block undo window retention, disconnect/reconnect set- # hash identity. Same target, no new allowlist entry. - add_executable(test_dash_mempool test_dash_mempool.cpp test_dash_replay_utxo_fold.cpp) + # Third TU: test_dash_fold_fee_source.cpp — W5-A FoldFeeSource: the + # graduated fold as the mempool's THIRD coin source. Red/green pair + # (ungraduated-prices-nothing vs graduated-prices-and-selects), spent / + # immature-coinbase exclusion, gate-FAIL poison+disarm, tip-lag trust + # drop, graduation restore across reopen (operator restatement required), + # prev-hash linkage reorg tripwire. Same target, no new allowlist entry. + add_executable(test_dash_mempool test_dash_mempool.cpp test_dash_replay_utxo_fold.cpp test_dash_fold_fee_source.cpp) target_link_libraries(test_dash_mempool PRIVATE GTest::gtest_main GTest::gtest dash_x11 core diff --git a/test/test_dash_fold_fee_source.cpp b/test/test_dash_fold_fee_source.cpp new file mode 100644 index 000000000..f1a1061f8 --- /dev/null +++ b/test/test_dash_fold_fee_source.cpp @@ -0,0 +1,453 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// W5-A KATs: FoldFeeSource (src/impl/dash/coin/fold_fee_source.hpp) — the +// graduated full-history UTXO fold as the mempool's THIRD coin source. +// +// The red/green pair the PR stands on: +// * UngraduatedFoldDoesNotPrice — the RED shape: a tx spending a coin the +// forward view cannot hold stays fee_known=false and excluded, exactly +// master's behaviour, even with the fold WIRED and holding the coin — +// because the fold has not passed the graduation gate. +// * GraduatedFoldPricesOldCoinAndSelectsIt — the GREEN: after a PASSING +// gate at a synthetic anchor, the same tx is priced with the exact fee +// and SELECTED into the template (both compute_fee_locked and the +// selection-time vin resolution consult the fold). +// Everything else pins the fail-toward-exclusion faces: spent coin, immature +// coinbase, gate FAIL (poison + disarm), tip lag, graduation restore across +// reopen (incl. the operator-restatement refusal), and the prev-hash linkage +// reorg tripwire. + +#include + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +using dash::coin::BlockType; +using dash::coin::FoldFeeSource; +using dash::coin::Mempool; +using dash::coin::MutableTransaction; +using dash::coin::TxIn; +using dash::coin::TxOut; +using dash::coin::dash_txid; +using dash::coin::replay::ReplayUtxoFold; +using dash::coin::replay::ReplayUtxoFoldOptions; +using dash::coin::replay::write_dashd_varint; + +namespace { + +struct TmpDir { + std::filesystem::path root; + TmpDir() + { + root = std::filesystem::temp_directory_path() / + ("c2pool_fold_fee_" + std::to_string(::getpid()) + "_" + + std::to_string(reinterpret_cast(this))); + std::filesystem::create_directories(root); + } + ~TmpDir() + { + std::error_code ec; + std::filesystem::remove_all(root, ec); + } + std::string sub(const char* name) const { return (root / name).string(); } +}; + +uint256 fake_block_hash(uint32_t height) +{ + std::vector seed = {'f', 'f', 's'}; + write_dashd_varint(seed, height); + return ::Hash(seed); +} + +std::vector p2pkh(uint8_t tag) +{ + std::vector s = {0x76, 0xa9, 0x14}; + for (int i = 0; i < 20; ++i) s.push_back(tag); + s.push_back(0x88); + s.push_back(0xac); + return s; +} + +MutableTransaction make_coinbase( + uint32_t height, + const std::vector>>& outs) +{ + MutableTransaction tx; + TxIn cin; + cin.prevout.hash = uint256(); + cin.prevout.index = 0xFFFFFFFF; + write_dashd_varint(cin.scriptSig.m_data, height); + cin.sequence = 0xFFFFFFFF; + tx.vin.push_back(cin); + for (const auto& [value, script] : outs) { + TxOut o; + o.value = value; + o.scriptPubKey.m_data = script; + tx.vout.push_back(o); + } + return tx; +} + +MutableTransaction make_spend( + const std::vector>& ins, + const std::vector>>& outs) +{ + MutableTransaction tx; + for (const auto& [txid, n] : ins) { + TxIn in; + in.prevout.hash = txid; + in.prevout.index = n; + in.sequence = 0xFFFFFFFF; + tx.vin.push_back(in); + } + for (const auto& [value, script] : outs) { + TxOut o; + o.value = value; + o.scriptPubKey.m_data = script; + tx.vout.push_back(o); + } + return tx; +} + +BlockType make_block(std::vector txs) +{ + BlockType b; + b.m_txs = std::move(txs); + return b; +} + +// The synthetic 3-block chain every test below folds: +// b1: cb1 (50 DASH → tag 1) [coinbase coin @1] +// b2: cb2 (50 DASH → tag 2), spend1(cb1:0 → 49 DASH → tag 3) +// — spend1:0 is THE OLD COIN: non-coinbase, height 2 +// b3: cb3 (50 DASH → tag 5) +struct Chain { + MutableTransaction cb1, cb2, spend1, cb3; + uint256 cb1_id, cb2_id, spend1_id; + BlockType b1, b2, b3; + Chain() + { + cb1 = make_coinbase(1, {{50'0000'0000LL, p2pkh(1)}}); + cb1_id = dash_txid(cb1); + cb2 = make_coinbase(2, {{50'0000'0000LL, p2pkh(2)}}); + cb2_id = dash_txid(cb2); + spend1 = make_spend({{cb1_id, 0}}, {{49'0000'0000LL, p2pkh(3)}}); + spend1_id = dash_txid(spend1); + cb3 = make_coinbase(3, {{50'0000'0000LL, p2pkh(5)}}); + b1 = make_block({cb1}); + b2 = make_block({cb2, spend1}); + b3 = make_block({cb3}); + } +}; + +// Fold the chain to height `upto` (2 or 3) in a throwaway store and return +// hash_serialized_2 at h=2 — the synthetic anchor value the gate tests +// demand. Computed by the SAME module the gate uses, in a separate store, so +// the gate test proves the LATCH mechanics (write/restore/refuse), while the +// hash CONSTRUCTION itself is pinned against an independent preimage in +// test_dash_replay_utxo_fold.cpp. +std::string anchor_hash_at_2(const Chain& c) +{ + TmpDir tmp; + ReplayUtxoFold f; + EXPECT_TRUE(f.open(tmp.sub("probe"))); + EXPECT_TRUE(f.on_replay_block(1, fake_block_hash(1), c.b1)); + EXPECT_TRUE(f.on_replay_block(2, fake_block_hash(2), c.b2)); + auto h = f.hash_serialized_2(); + EXPECT_TRUE(h.has_value()); + return h->hash.GetHex(); +} + +ReplayUtxoFoldOptions gate_at_2(const std::string& expect_hex) +{ + ReplayUtxoFoldOptions o; + o.gate_anchor_height = 2; + o.gate_anchor_expect = expect_hex; + return o; +} + +} // namespace + +// ── KAT (c): an UNGRADUATED fold prices NOTHING (the red shape) ──────────── +TEST(FoldFeeSource, UngraduatedFoldDoesNotPrice) +{ + Chain c; + TmpDir tmp; + // Anchor far beyond the chain: the gate can never fire. + ReplayUtxoFoldOptions o; + o.gate_anchor_height = 100; + ReplayUtxoFold fold(o); + ASSERT_TRUE(fold.open(tmp.sub("db"))); + FoldFeeSource::Options so; + so.operator_expect = std::string(64, 'a'); + FoldFeeSource src(fold, so); + ASSERT_TRUE(src.feed(1, fake_block_hash(1), c.b1)); + ASSERT_TRUE(src.feed(2, fake_block_hash(2), c.b2)); + ASSERT_TRUE(src.feed(3, fake_block_hash(3), c.b3)); + src.note_tip(3); + EXPECT_FALSE(src.graduated()); + EXPECT_FALSE(src.trusted()); + + Mempool pool; + ::core::coin::UTXOViewCache view(nullptr); // forward view: EMPTY + pool.set_utxo(&view); + pool.set_fee_coin_lookup( + [&src](const ::core::coin::Outpoint& op, ::core::coin::Coin& out) { + return src.lookup(op, out); + }); + // Spends the old coin (spend1:0) the fold HOLDS — but the fold is not + // graduated, so the tx must stay fee-unknown and excluded (master's + // behaviour, byte-identical). + auto tx = make_spend({{c.spend1_id, 0}}, + {{49'0000'0000LL - 100000, p2pkh(9)}}); + ASSERT_TRUE(pool.add_tx(tx)); + auto e = pool.get_entry(dash_txid(tx)); + ASSERT_TRUE(e.has_value()); + EXPECT_FALSE(e->fee_known); + auto [sel, fees] = pool.get_sorted_txs_with_fees(1'000'000, false, 4, 0); + EXPECT_TRUE(sel.empty()); + EXPECT_EQ(fees, 0u); +} + +// ── KAT (a): a GRADUATED fold prices the old coin and it gets SELECTED ───── +TEST(FoldFeeSource, GraduatedFoldPricesOldCoinAndSelectsIt) +{ + Chain c; + const std::string expect = anchor_hash_at_2(c); + + TmpDir tmp; + ReplayUtxoFold fold(gate_at_2(expect)); + ASSERT_TRUE(fold.open(tmp.sub("db"))); + FoldFeeSource::Options so; + so.operator_expect = expect; + FoldFeeSource src(fold, so); + ASSERT_TRUE(src.feed(1, fake_block_hash(1), c.b1)); + ASSERT_TRUE(src.feed(2, fake_block_hash(2), c.b2)); // ← gate fires, PASS + EXPECT_TRUE(src.graduated()); + ASSERT_TRUE(src.feed(3, fake_block_hash(3), c.b3)); + src.note_tip(3); + EXPECT_TRUE(src.trusted()); + + Mempool pool; + ::core::coin::UTXOViewCache view(nullptr); + pool.set_utxo(&view); + pool.set_fee_coin_lookup( + [&src](const ::core::coin::Outpoint& op, ::core::coin::Coin& out) { + return src.lookup(op, out); + }); + const uint64_t kFee = 100000; + auto tx = make_spend({{c.spend1_id, 0}}, + {{49'0000'0000LL - static_cast(kFee), + p2pkh(9)}}); + ASSERT_TRUE(pool.add_tx(tx)); + auto e = pool.get_entry(dash_txid(tx)); + ASSERT_TRUE(e.has_value()); + EXPECT_TRUE(e->fee_known); + EXPECT_EQ(e->fee, kFee); + // The selection-time vin resolution must ALSO resolve through the fold — + // pricing alone would be undone by the stale-input guard. + auto [sel, fees] = pool.get_sorted_txs_with_fees(1'000'000, false, 4, 0); + ASSERT_EQ(sel.size(), 1u); + EXPECT_EQ(dash_txid(sel[0].tx), dash_txid(tx)); + EXPECT_EQ(fees, kFee); +} + +// ── Spent-in-fold ⇒ excluded (a spent coin never becomes an input value) ─── +TEST(FoldFeeSource, SpentCoinInFoldNotPriced) +{ + Chain c; + const std::string expect = anchor_hash_at_2(c); + TmpDir tmp; + ReplayUtxoFold fold(gate_at_2(expect)); + ASSERT_TRUE(fold.open(tmp.sub("db"))); + FoldFeeSource::Options so; + so.operator_expect = expect; + FoldFeeSource src(fold, so); + ASSERT_TRUE(src.feed(1, fake_block_hash(1), c.b1)); + ASSERT_TRUE(src.feed(2, fake_block_hash(2), c.b2)); + ASSERT_TRUE(src.feed(3, fake_block_hash(3), c.b3)); + src.note_tip(3); + ASSERT_TRUE(src.trusted()); + + Mempool pool; + ::core::coin::UTXOViewCache view(nullptr); + pool.set_utxo(&view); + pool.set_fee_coin_lookup( + [&src](const ::core::coin::Outpoint& op, ::core::coin::Coin& out) { + return src.lookup(op, out); + }); + // cb1:0 was SPENT by spend1 in block 2 — the fold answers false. + auto tx = make_spend({{c.cb1_id, 0}}, {{10'0000'0000LL, p2pkh(9)}}); + ASSERT_TRUE(pool.add_tx(tx)); + auto e = pool.get_entry(dash_txid(tx)); + ASSERT_TRUE(e.has_value()); + EXPECT_FALSE(e->fee_known); +} + +// ── Immature coinbase in the fold ⇒ excluded (dashd would not pool it) ───── +TEST(FoldFeeSource, ImmatureCoinbaseInFoldNotPriced) +{ + Chain c; + const std::string expect = anchor_hash_at_2(c); + TmpDir tmp; + ReplayUtxoFold fold(gate_at_2(expect)); + ASSERT_TRUE(fold.open(tmp.sub("db"))); + FoldFeeSource::Options so; + so.operator_expect = expect; + FoldFeeSource src(fold, so); + ASSERT_TRUE(src.feed(1, fake_block_hash(1), c.b1)); + ASSERT_TRUE(src.feed(2, fake_block_hash(2), c.b2)); + ASSERT_TRUE(src.feed(3, fake_block_hash(3), c.b3)); + src.note_tip(3); + ASSERT_TRUE(src.trusted()); + + Mempool pool; + ::core::coin::UTXOViewCache view(nullptr); + pool.set_utxo(&view); + pool.set_fee_coin_lookup( + [&src](const ::core::coin::Outpoint& op, ::core::coin::Coin& out) { + return src.lookup(op, out); + }); + // cb2:0 is a COINBASE coin at h=2; spend height 4 << 2+100. + auto tx = make_spend({{c.cb2_id, 0}}, {{49'0000'0000LL, p2pkh(9)}}); + ASSERT_TRUE(pool.add_tx(tx)); + auto e = pool.get_entry(dash_txid(tx)); + ASSERT_TRUE(e.has_value()); + EXPECT_FALSE(e->fee_known); +} + +// ── Gate FAIL: fold poisoned, lane stopped, source disarmed, no record ───── +TEST(FoldFeeSource, GateFailPoisonsAndDisarms) +{ + Chain c; + TmpDir tmp; + const std::string wrong(64, 'e'); + ReplayUtxoFold fold(gate_at_2(wrong)); + ASSERT_TRUE(fold.open(tmp.sub("db"))); + FoldFeeSource::Options so; + so.operator_expect = wrong; // operator agrees with the (wrong) anchor + FoldFeeSource src(fold, so); + ASSERT_TRUE(src.feed(1, fake_block_hash(1), c.b1)); + // The gate fires at h=2 and FAILS: feed refuses (stops a bulk lane), + // the fold is poisoned, nothing is written, the source is disarmed. + EXPECT_FALSE(src.feed(2, fake_block_hash(2), c.b2)); + EXPECT_TRUE(fold.poisoned()); + EXPECT_FALSE(src.graduated()); + EXPECT_TRUE(src.disarmed()); + EXPECT_FALSE(fold.graduation().has_value()); + // Poison latches: further blocks refuse. + EXPECT_FALSE(src.feed(3, fake_block_hash(3), c.b3)); + src.note_tip(3); + EXPECT_FALSE(src.trusted()); + ::core::coin::Coin out; + EXPECT_FALSE(src.lookup( + ::core::coin::Outpoint(c.spend1_id, 0), out)); +} + +// ── Trust condition 2: a fold behind the tip prices nothing ──────────────── +TEST(FoldFeeSource, TipLagDropsTrust) +{ + Chain c; + const std::string expect = anchor_hash_at_2(c); + TmpDir tmp; + ReplayUtxoFold fold(gate_at_2(expect)); + ASSERT_TRUE(fold.open(tmp.sub("db"))); + FoldFeeSource::Options so; + so.operator_expect = expect; + FoldFeeSource src(fold, so); + ASSERT_TRUE(src.feed(1, fake_block_hash(1), c.b1)); + ASSERT_TRUE(src.feed(2, fake_block_hash(2), c.b2)); + ASSERT_TRUE(src.feed(3, fake_block_hash(3), c.b3)); + src.note_tip(3); + ASSERT_TRUE(src.trusted()); + ::core::coin::Coin out; + EXPECT_TRUE(src.lookup(::core::coin::Outpoint(c.spend1_id, 0), out)); + // The chain advances without the fold: trust drops, lookups go dark. + src.note_tip(10); + EXPECT_FALSE(src.trusted()); + EXPECT_FALSE(src.lookup(::core::coin::Outpoint(c.spend1_id, 0), out)); +} + +// ── Graduation survives a reopen — ONLY with the matching restatement ────── +TEST(FoldFeeSource, GraduationRestoreAcrossReopen) +{ + Chain c; + const std::string expect = anchor_hash_at_2(c); + TmpDir tmp; + const std::string db = tmp.sub("db"); + { + ReplayUtxoFold fold(gate_at_2(expect)); + ASSERT_TRUE(fold.open(db)); + FoldFeeSource::Options so; + so.operator_expect = expect; + FoldFeeSource src(fold, so); + ASSERT_TRUE(src.feed(1, fake_block_hash(1), c.b1)); + ASSERT_TRUE(src.feed(2, fake_block_hash(2), c.b2)); + ASSERT_TRUE(src.feed(3, fake_block_hash(3), c.b3)); + ASSERT_TRUE(src.graduated()); + fold.close(); + } + { + // Same DB, correct restatement: trust restores WITHOUT re-measuring. + ReplayUtxoFold fold(gate_at_2(expect)); + ASSERT_TRUE(fold.open(db)); + FoldFeeSource::Options so; + so.operator_expect = expect; + FoldFeeSource src(fold, so); + EXPECT_TRUE(src.graduated()); + src.note_tip(3); + EXPECT_TRUE(src.trusted()); + fold.close(); + } + { + // Same DB, WRONG restatement: the record is ignored — a foreign or + // stale DB cannot graduate on its own say-so. + ReplayUtxoFold fold(gate_at_2(expect)); + ASSERT_TRUE(fold.open(db)); + FoldFeeSource::Options so; + so.operator_expect = std::string(64, 'd'); + FoldFeeSource src(fold, so); + EXPECT_FALSE(src.graduated()); + src.note_tip(3); + EXPECT_FALSE(src.trusted()); + } +} + +// ── Reorg tripwire: prev-hash linkage refusal (opt-in, adapter arms it) ──── +TEST(ReplayUtxoFoldGate, PrevLinkageRefusesOrphanFold) +{ + Chain c; + TmpDir tmp; + ReplayUtxoFoldOptions o; + o.check_prev_linkage = true; + o.gate_anchor_height = 100; + ReplayUtxoFold fold(o); + ASSERT_TRUE(fold.open(tmp.sub("db"))); + ASSERT_TRUE(fold.on_replay_block(1, fake_block_hash(1), c.b1)); + // b2 with the CORRECT prev link folds fine. + BlockType linked = c.b2; + linked.m_previous_block = fake_block_hash(1); + ASSERT_TRUE(fold.on_replay_block(2, fake_block_hash(2), linked)); + // b3 claiming a DIFFERENT parent (an orphaned h=2) is REFUSED — silently + // folding across a reorg is structurally impossible. + BlockType orphan_child = c.b3; + orphan_child.m_previous_block = fake_block_hash(99); + EXPECT_FALSE(fold.on_replay_block(3, fake_block_hash(3), orphan_child)); + EXPECT_NE(fold.refusal().find("replay-utxo-prev-mismatch"), + std::string::npos); + // The correctly-linked child is still accepted afterwards. + BlockType good_child = c.b3; + good_child.m_previous_block = fake_block_hash(2); + EXPECT_TRUE(fold.on_replay_block(3, fake_block_hash(3), good_child)); +}