diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d67605b21..758e02981 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -69,7 +69,7 @@ jobs: test_address_resolution test_compute_share_target \ test_utxo test_dgb_subsidy test_dgb_coinbase_value dgb_share_test dgb_block_assembly_test dgb_header_sample_build_test dgb_header_ingest_test \ dgb_gentx_coinbase_test nmc_auxpow_merkle_test nmc_template_builder_test nmc_auxpow_wire_test dgb_gentx_share_path_test dgb_other_tx_resolver_test \ - dgb_other_tx_assembler_test dgb_reconstruct_won_block_test dgb_gentx_unpack_test dgb_work_source_test dgb_template_builder_test dgb_embedded_coin_node_test \ + dgb_other_tx_assembler_test dgb_reconstruct_won_block_test dgb_gentx_unpack_test dgb_work_source_test dgb_template_builder_test dgb_embedded_coin_node_test dgb_embedded_tx_select_test \ rpc_request_test softfork_check_test genesis_check_test algo_select_test digishield_walk_test header_chain_test \ dgb_coin_node_seam_test dgb_block_broadcast_test dgb_won_block_dispatch_test \ v37_test \ @@ -202,7 +202,7 @@ jobs: test_address_resolution test_compute_share_target \ test_utxo test_dgb_subsidy test_dgb_coinbase_value dgb_share_test dgb_block_assembly_test dgb_header_sample_build_test dgb_header_ingest_test \ dgb_gentx_coinbase_test nmc_auxpow_merkle_test nmc_template_builder_test nmc_auxpow_wire_test dgb_gentx_share_path_test dgb_other_tx_resolver_test \ - dgb_other_tx_assembler_test dgb_reconstruct_won_block_test dgb_gentx_unpack_test dgb_work_source_test dgb_template_builder_test dgb_embedded_coin_node_test \ + dgb_other_tx_assembler_test dgb_reconstruct_won_block_test dgb_gentx_unpack_test dgb_work_source_test dgb_template_builder_test dgb_embedded_coin_node_test dgb_embedded_tx_select_test \ rpc_request_test softfork_check_test genesis_check_test algo_select_test digishield_walk_test header_chain_test \ dgb_coin_node_seam_test dgb_block_broadcast_test dgb_won_block_dispatch_test \ test_coin_broadcaster test_multiaddress_pplns test_pplns_stress \ diff --git a/src/impl/dgb/coin/CMakeLists.txt b/src/impl/dgb/coin/CMakeLists.txt index 70b2cdb7d..57701b8de 100644 --- a/src/impl/dgb/coin/CMakeLists.txt +++ b/src/impl/dgb/coin/CMakeLists.txt @@ -26,6 +26,9 @@ set(dgb_coin_interface header_chain.hpp mempool.hpp template_builder.hpp + embedded_coinbase_value.hpp + embedded_coin_node.hpp + embedded_tx_select.hpp embedded_tx_select.cpp ) add_library(dgb_coin ${dgb_coin_interface} ${dgb_coin_impl}) diff --git a/src/impl/dgb/coin/embedded_coin_node.hpp b/src/impl/dgb/coin/embedded_coin_node.hpp index 6fcc1f54c..b070e473e 100644 --- a/src/impl/dgb/coin/embedded_coin_node.hpp +++ b/src/impl/dgb/coin/embedded_coin_node.hpp @@ -20,9 +20,13 @@ // The external-daemon GBT fallback (NodeRPC::getwork) is a SEPARATE path // and is untouched -- this node is the no-external-daemon source only. // * version pins the Scrypt lane (build_work_template, DGB_BLOCK_VERSION_SCRYPT). -// * transactions[] stays empty and total_fees is 0: embedded mempool tx -// selection is not wired yet, so the template fabricates no transactions -// (the same truthful-absence discipline the stratum caller keeps). +// * transactions[] + the fee total come from an injected EmbeddedTxSource +// (default empty -> empty transactions[], 0 fees, byte-identical to the +// pre-tx-select call site). The production source (embedded_tx_select.cpp, +// make_mempool_tx_source) selects fee-sorted mempool txs; their fee total +// is folded into coinbasevalue via resolve_coinbase_value (#207), never +// fabricated, and transactions[] is passed through build_work_template +// verbatim. // * previousblockhash is emitted ONLY when HeaderChain::tip_hash() carries a // real tip id, rendered through the coin/hash_format.hpp SSOT so it is // byte-identical to the stratum caller's previousblockhash. @@ -39,6 +43,8 @@ #include #include #include +#include +#include #include // core::SubsidyFunc @@ -51,12 +57,37 @@ namespace dgb::coin { +// Caller-supplied embedded transaction selection. Decouples EmbeddedCoinNode +// from the heavy tx/UTXO codec (mempool.hpp -> transaction.hpp pulls the +// btclibs serialization templates -- the #143 SCC trap): the node consumes +// only the ALREADY-SHAPED GBT transactions[] array + the fee total, both plain +// JSON/integer, so this header stays codec-free and a guard-weight TU that +// reaches it never compiles the serialization templates. The production shaper +// lives out-of-line in embedded_tx_select.cpp (make_mempool_tx_source). +struct EmbeddedTxSelection +{ + // GBT transactions[] array, per-tx {data,txid,hash,fee} -- build_work_template + // passes it through verbatim. Empty array = no txs selected. + nlohmann::json transactions = nlohmann::json::array(); + // Sum of the known fees across the selected txs, folded into coinbasevalue + // via the #207 resolve_coinbase_value SSOT (NOT added to the template here). + uint64_t total_fees = 0; +}; + +// Injected transaction source. An empty std::function (the default) means NO +// transaction source is wired: the node emits an empty transactions[] and 0 +// fees -- byte-identical to the pre-tx-select #237 call site. The production +// wiring injects make_mempool_tx_source(mempool, max_weight). +using EmbeddedTxSource = std::function; + class EmbeddedCoinNode : public CoinNodeInterface { public: EmbeddedCoinNode(c2pool::dgb::HeaderChain& chain, - core::SubsidyFunc subsidy_func) - : m_chain(chain), m_subsidy_func(std::move(subsidy_func)) + core::SubsidyFunc subsidy_func, + EmbeddedTxSource tx_source = {}) + : m_chain(chain), m_subsidy_func(std::move(subsidy_func)), + m_tx_source(std::move(tx_source)) { } @@ -67,15 +98,26 @@ class EmbeddedCoinNode : public CoinNodeInterface { const uint32_t height = m_chain.next_block_height(); + // Pull the caller-supplied transaction selection (empty when no source + // is wired -> byte-identical to the pre-tx-select template). The heavy + // codec that shapes these lives out-of-line (embedded_tx_select.cpp); + // this node only folds the fee total into coinbasevalue and passes the + // transactions[] array through build_work_template. + EmbeddedTxSelection sel; + if (m_tx_source) + sel = m_tx_source(); + WorkTemplateInputs in; in.next_height = height; - // Embedded path: no external GBT value -> derive subsidy(height)+fees. - // total_fees is 0 until embedded mempool tx selection is wired. + // Embedded path: no external GBT value -> derive subsidy(height)+fees + // via the #207 SSOT. total_fees comes from the selected txs (0 when no + // source is wired). in.coinbasevalue = resolve_coinbase_value(m_subsidy_func, height, - /*total_fees=*/0, + /*total_fees=*/sel.total_fees, /*gbt_coinbasevalue=*/std::nullopt); in.median_time_past = m_chain.median_time_past(); in.curtime = curtime; + in.transactions = std::move(sel.transactions); if (auto th = m_chain.tip_hash()) in.previousblockhash = u256_be_display_hex(*th); return in; @@ -107,6 +149,7 @@ class EmbeddedCoinNode : public CoinNodeInterface private: c2pool::dgb::HeaderChain& m_chain; core::SubsidyFunc m_subsidy_func; + EmbeddedTxSource m_tx_source; }; } // namespace dgb::coin diff --git a/src/impl/dgb/coin/embedded_tx_select.cpp b/src/impl/dgb/coin/embedded_tx_select.cpp new file mode 100644 index 000000000..1f04f531e --- /dev/null +++ b/src/impl/dgb/coin/embedded_tx_select.cpp @@ -0,0 +1,64 @@ +// =========================================================================== +// embedded_tx_select.cpp -- out-of-line shaper for the embedded tx source. +// +// The tx/UTXO serialization codec (TX_WITH_WITNESS pack + Hash + HexStr) lives +// here, NOT in any header, so embedded_coin_node.hpp stays codec-free and the +// #143 btclibs SCC trap stays shut. Compiled into dgb_coin (which already links +// the full btclibs serialization), so the codec resolves at link time. +// +// Mirrors the per-tx GBT entry shape src/impl/btc/coin/template_builder.hpp +// emits, so c2pool-dgb and the p2pool-dgb-scrypt reference consume an identical +// transactions[] form (p2pool reads `fee` in helper.py:123 and adjusts subsidy +// in data.py:876-884 when a tx is excluded from the share). +// =========================================================================== + +#include "embedded_tx_select.hpp" + +#include "transaction.hpp" // TX_WITH_WITNESS, compute_txid is in mempool.hpp + +#include // pack +#include // Hash (sha256d) +#include // HexStr + +#include +#include + +namespace dgb::coin +{ + +EmbeddedTxSource make_mempool_tx_source(Mempool& pool, uint32_t max_weight) +{ + return [&pool, max_weight]() -> EmbeddedTxSelection { + auto [selected, total_fees] = pool.get_sorted_txs_with_fees(max_weight); + + EmbeddedTxSelection out; + out.total_fees = total_fees; + + nlohmann::json tx_array = nlohmann::json::array(); + for (const auto& stx : selected) + { + // data = with-witness serialization hex (what a daemon submits) + // txid = legacy (non-witness) sha256d + // hash = wtxid (with-witness sha256d) for the witness merkle tree + const uint256 txid = compute_txid(stx.tx); + auto packed = pack(TX_WITH_WITNESS(stx.tx)); + const std::string hex_data = HexStr(packed.get_span()); + const uint256 wtxid = Hash(packed.get_span()); + + nlohmann::json entry; + entry["data"] = hex_data; + entry["txid"] = txid.GetHex(); + entry["hash"] = wtxid.GetHex(); + if (stx.fee_known) + entry["fee"] = static_cast(stx.fee); + else + entry["fee"] = nullptr; // JSON null -> p2pool base_subsidy fallback + tx_array.push_back(std::move(entry)); + } + + out.transactions = std::move(tx_array); + return out; + }; +} + +} // namespace dgb::coin diff --git a/src/impl/dgb/coin/embedded_tx_select.hpp b/src/impl/dgb/coin/embedded_tx_select.hpp new file mode 100644 index 000000000..cfbc662d9 --- /dev/null +++ b/src/impl/dgb/coin/embedded_tx_select.hpp @@ -0,0 +1,40 @@ +#pragma once +// =========================================================================== +// embedded_tx_select.hpp -- production EmbeddedTxSource factory. +// +// Builds the dgb::coin::EmbeddedTxSource that EmbeddedCoinNode injects to fill +// the embedded work template's transactions[] from the in-process Mempool. The +// actual shaping (Mempool::SelectedTx -> {data,txid,hash,fee}) is the heavy +// tx/UTXO serialization codec, so it lives OUT-OF-LINE in embedded_tx_select.cpp +// (compiled into dgb_coin) -- NOT in this header and NOT in embedded_coin_node.hpp. +// Keeping the codec in a .cpp is what holds the #143 btclibs SCC trap shut: a +// guard-weight TU that reaches embedded_coin_node.hpp never compiles the +// transaction serialization templates. +// +// This header DOES pull mempool.hpp (-> transaction.hpp), so include it ONLY +// from TUs that already link the full dgb_coin codec (main_dgb.cpp + the codec +// conformance test), never from a guard-weight TU. +// =========================================================================== + +#include + +#include "embedded_coin_node.hpp" // EmbeddedTxSource / EmbeddedTxSelection +#include "mempool.hpp" // dgb::coin::Mempool + +namespace dgb::coin +{ + +// Build an EmbeddedTxSource that, on each invocation, selects up to max_weight +// BIP141 weight units of fee-sorted transactions from `pool` and shapes them +// into the GBT transactions[] form build_work_template passes through verbatim: +// {data: hex(TX_WITH_WITNESS), txid: txid, hash: wtxid, fee: } +// The returned selection's total_fees is the sum of the known fees across the +// selected txs (the SAME figure get_sorted_txs_with_fees reports), folded into +// coinbasevalue by EmbeddedCoinNode via resolve_coinbase_value (#207 SSOT) -- +// NOT added to the template here. +// +// `pool` is captured by reference -- it MUST outlive the returned source (the +// embedded node and its mempool share a lifetime). +EmbeddedTxSource make_mempool_tx_source(Mempool& pool, uint32_t max_weight); + +} // namespace dgb::coin diff --git a/src/impl/dgb/test/CMakeLists.txt b/src/impl/dgb/test/CMakeLists.txt index 396b99d14..637881367 100644 --- a/src/impl/dgb/test/CMakeLists.txt +++ b/src/impl/dgb/test/CMakeLists.txt @@ -201,6 +201,21 @@ if (BUILD_TESTING AND GTest_FOUND) nlohmann_json::nlohmann_json) gtest_add_tests(dgb_embedded_coin_node_test "" AUTO) + # dgb_embedded_tx_select_test: guards make_mempool_tx_source + # (embedded_tx_select.cpp), the production Mempool -> GBT transactions[] + # {data,txid,hash,fee} + fee-total shaper EmbeddedCoinNode injects. Compiles + # the tx serialization codec, so it links the same proven SCC set as + # dgb_embedded_coin_node_test. MUST also be in BOTH build.yml --target + # allowlists (#143 NOT_BUILT trap). + add_executable(dgb_embedded_tx_select_test embedded_tx_select_test.cpp) + target_link_libraries(dgb_embedded_tx_select_test PRIVATE + GTest::gtest_main GTest::gtest + core dgb dgb_stratum + c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage + dgb_coin pool sharechain + nlohmann_json::nlohmann_json) + gtest_add_tests(dgb_embedded_tx_select_test "" AUTO) + foreach(dgb_guard rpc_request_test softfork_check_test genesis_check_test algo_select_test digishield_walk_test) add_executable(${dgb_guard} ${dgb_guard}.cpp) diff --git a/src/impl/dgb/test/embedded_coin_node_test.cpp b/src/impl/dgb/test/embedded_coin_node_test.cpp index 6d6dd3e39..de65445e1 100644 --- a/src/impl/dgb/test/embedded_coin_node_test.cpp +++ b/src/impl/dgb/test/embedded_coin_node_test.cpp @@ -135,3 +135,44 @@ TEST(DgbEmbeddedCoinNode, GetworkProducesTemplateWithNoFabrication) EXPECT_TRUE(wd.m_hashes.empty()); // no tx hashes fabricated EXPECT_FALSE(node.is_synced()); // truthful: not synced yet } + +// 5. Injected tx source: the fee total folds into coinbasevalue via the #207 +// SSOT and transactions[] passes through build_work_template verbatim. +// Codec-free: a stub source supplies an ALREADY-SHAPED selection, exactly +// as make_mempool_tx_source would, so this guard never compiles the tx +// serialization codec (the embedded_tx_select.cpp / dgb_embedded_tx_select_test +// pins the real shaping over a Mempool). +TEST(DgbEmbeddedCoinNode, InjectedTxSourceFoldsFeesAndPassesTransactions) +{ + HeaderChain hc; + hc.validate_and_append(HeaderSample{SCRYPT, 1000, 100}); + + nlohmann::json txs = nlohmann::json::array(); + nlohmann::json e1; e1["data"]="00"; e1["txid"]="aa"; e1["hash"]="aa"; e1["fee"]=700; + nlohmann::json e2; e2["data"]="01"; e2["txid"]="bb"; e2["hash"]="bb"; e2["fee"]=300; + txs.push_back(e1); txs.push_back(e2); + const uint64_t fees = 1000; + + EmbeddedCoinNode node(hc, make_subsidy(), + [txs, fees]() -> dgb::coin::EmbeddedTxSelection { return {txs, fees}; }); + + const uint32_t h = hc.next_block_height(); + const nlohmann::json t = build_work_template(node.make_inputs(7)); + EXPECT_EQ(t["coinbasevalue"].get(), 1000ull + h + fees); // subsidy(h)+fees + EXPECT_EQ(t["transactions"], txs); // verbatim pass-through + EXPECT_EQ(t["transactions"].size(), 2u); +} + +// 6. Empty (default) tx source == byte-identical to the no-source template +// (the #237 call-site invariant: a node with no tx source fabricates nothing). +TEST(DgbEmbeddedCoinNode, EmptyTxSourceByteIdenticalToNoSource) +{ + HeaderChain hc; + hc.validate_and_append(HeaderSample{SCRYPT, 1000, 100}); + + EmbeddedCoinNode no_src(hc, make_subsidy()); + EmbeddedCoinNode empty_src(hc, make_subsidy(), dgb::coin::EmbeddedTxSource{}); + + EXPECT_EQ(build_work_template(empty_src.make_inputs(5)).dump(), + build_work_template(no_src.make_inputs(5)).dump()); +} diff --git a/src/impl/dgb/test/embedded_tx_select_test.cpp b/src/impl/dgb/test/embedded_tx_select_test.cpp new file mode 100644 index 000000000..e6f1b9ee7 --- /dev/null +++ b/src/impl/dgb/test/embedded_tx_select_test.cpp @@ -0,0 +1,127 @@ +// --------------------------------------------------------------------------- +// dgb_embedded_tx_select_test -- pins make_mempool_tx_source (embedded_tx_select.cpp), +// the production shaper that turns the embedded Mempool's fee-sorted selection +// into the GBT transactions[] form build_work_template passes through. +// +// CONFORMANCE (frstrtr/p2pool-dgb-scrypt): the per-tx entry shape +// {data,txid,hash,fee} and the total_fees fold are exactly what p2pool's GBT +// consumer reads -- `data` is the with-witness submit bytes, `txid` the legacy +// sha256d, `hash` the wtxid for the witness merkle tree, `fee` the per-tx fee +// (null when unknown). The coinbasevalue fold (subsidy(h)+total_fees) is +// asserted in dgb_embedded_coin_node_test against the #207 SSOT; here we pin +// the byte-level shaping the .cpp does over a real Mempool. +// +// Links the full dgb_coin codec like dgb_embedded_coin_node_test (it compiles +// the tx serialization). MUST be in BOTH build.yml --target allowlists (#143 +// NOT_BUILT trap). +// --------------------------------------------------------------------------- + +#include +#include +#include + +#include + +#include +#include +#include + +#include +#include +#include + +using dgb::coin::Mempool; +using dgb::coin::MutableTransaction; +using dgb::coin::TxIn; +using dgb::coin::TxOut; +using dgb::coin::TX_WITH_WITNESS; +using dgb::coin::compute_txid; +using dgb::coin::make_mempool_tx_source; + +namespace { + +// A minimal, distinct tx tagged by its output value (mirrors the builder used +// across the dgb won-block tests). `index` keeps the prevout distinct so each +// tx has a distinct txid. +MutableTransaction tagged_tx(int64_t value, uint32_t index) +{ + MutableTransaction tx; + tx.version = 1; + tx.locktime = 0; + TxIn in; + in.prevout.hash.SetNull(); + in.prevout.index = index; + in.sequence = 0xffffffff; + tx.vin.push_back(in); + TxOut out; + out.value = value; + tx.vout.push_back(out); + return tx; +} + +// Expected GBT entry shape for a tx, computed independently of the shaper. +nlohmann::json expect_entry(const MutableTransaction& tx, int64_t fee) +{ + auto packed = pack(TX_WITH_WITNESS(tx)); + nlohmann::json e; + e["data"] = HexStr(packed.get_span()); + e["txid"] = compute_txid(tx).GetHex(); + e["hash"] = Hash(packed.get_span()).GetHex(); + e["fee"] = fee; + return e; +} + +} // namespace + +// Two fee-known txs: total_fees == sum, entries shaped {data,txid,hash,fee}. +TEST(DgbEmbeddedTxSelect, ShapesKnownFeeTxsAndSumsFees) +{ + Mempool pool; + MutableTransaction a = tagged_tx(10, 0); + MutableTransaction b = tagged_tx(20, 1); + ASSERT_TRUE(pool.add_tx(a)); + ASSERT_TRUE(pool.add_tx(b)); + pool.set_tx_fee(compute_txid(a), 700); + pool.set_tx_fee(compute_txid(b), 300); + + auto source = make_mempool_tx_source(pool, /*max_weight=*/4'000'000); + const auto sel = source(); + + EXPECT_EQ(sel.total_fees, 1000u); // 700 + 300 + ASSERT_TRUE(sel.transactions.is_array()); + EXPECT_EQ(sel.transactions.size(), 2u); + + // Each selected tx must carry the exact independently-computed entry. The + // selection is feerate-sorted, so match by txid rather than position. + for (const auto& want : {expect_entry(a, 700), expect_entry(b, 300)}) + { + bool found = false; + for (const auto& got : sel.transactions) + if (got["txid"] == want["txid"]) { EXPECT_EQ(got, want); found = true; } + EXPECT_TRUE(found) << "missing txid " << want["txid"]; + } +} + +// Empty mempool -> empty transactions[] + zero fees (truthful absence). +TEST(DgbEmbeddedTxSelect, EmptyPoolEmptySelection) +{ + Mempool pool; + auto source = make_mempool_tx_source(pool, /*max_weight=*/4'000'000); + const auto sel = source(); + EXPECT_EQ(sel.total_fees, 0u); + ASSERT_TRUE(sel.transactions.is_array()); + EXPECT_TRUE(sel.transactions.empty()); +} + +// Unknown-fee txs are excluded from the template (get_sorted_txs_with_fees +// only emits fee-known txs -- including fee=0 would desync coinbasevalue vs the +// p2pool/daemon GBT figure). So a pool with only unknown-fee txs selects none. +TEST(DgbEmbeddedTxSelect, UnknownFeeTxExcluded) +{ + Mempool pool; + ASSERT_TRUE(pool.add_tx(tagged_tx(10, 0))); // no set_tx_fee -> fee_known=false + auto source = make_mempool_tx_source(pool, /*max_weight=*/4'000'000); + const auto sel = source(); + EXPECT_EQ(sel.total_fees, 0u); + EXPECT_TRUE(sel.transactions.empty()); +}