diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2dbf796a1..881499708 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -84,8 +84,7 @@ jobs: dgb_gentx_coinbase_test nmc_auxpow_merkle_test nmc_template_builder_test nmc_auxpow_wire_test nmc_reconstruct_won_block_test dgb_gentx_share_path_test dgb_other_tx_resolver_test \ dgb_other_tx_assembler_test dgb_reconstruct_won_block_test dgb_reconstruct_closure_test dgb_gentx_unpack_test dgb_work_source_test dgb_template_builder_test dgb_embedded_coin_node_test dgb_embedded_tx_select_test dgb_coinbase_value_parity_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 dgb_forced_won_share_dualpath_test dgb_scrypt_pow_test dgb_nonce_grinder_test dgb_regrind_block_test \ - v37_test \ + dgb_coin_node_seam_test dgb_block_broadcast_test dgb_won_block_dispatch_test dgb_forced_won_share_dualpath_test dgb_scrypt_pow_test dgb_nonce_grinder_test dgb_regrind_block_test dgb_won_block_finalize_test v37_test \ -j$(nproc) - name: Run tests @@ -217,8 +216,7 @@ jobs: dgb_gentx_coinbase_test nmc_auxpow_merkle_test nmc_template_builder_test nmc_auxpow_wire_test nmc_reconstruct_won_block_test dgb_gentx_share_path_test dgb_other_tx_resolver_test \ dgb_other_tx_assembler_test dgb_reconstruct_won_block_test dgb_reconstruct_closure_test dgb_gentx_unpack_test dgb_work_source_test dgb_template_builder_test dgb_embedded_coin_node_test dgb_embedded_tx_select_test dgb_coinbase_value_parity_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 dgb_forced_won_share_dualpath_test dgb_scrypt_pow_test dgb_nonce_grinder_test dgb_regrind_block_test \ - test_coin_broadcaster test_multiaddress_pplns test_pplns_stress \ + dgb_coin_node_seam_test dgb_block_broadcast_test dgb_won_block_dispatch_test dgb_forced_won_share_dualpath_test dgb_scrypt_pow_test dgb_nonce_grinder_test dgb_regrind_block_test dgb_won_block_finalize_test test_coin_broadcaster test_multiaddress_pplns test_pplns_stress \ v37_test \ -j$(nproc) diff --git a/scripts/dgb_regtest_won_block_soak.sh b/scripts/dgb_regtest_won_block_soak.sh index bdcef3dc0..0eb52fa2a 100755 --- a/scripts/dgb_regtest_won_block_soak.sh +++ b/scripts/dgb_regtest_won_block_soak.sh @@ -92,6 +92,21 @@ provision "$DATADIR_B" "$RPCPORT_B" wait_rpc() { local n=0; until "$@" getblockcount >/dev/null 2>&1; do n=$((n+1)); [ $n -gt 60 ] && die "rpc never came up: $*"; sleep 0.5; done; } tip_b() { cli_b getblockcount; } +# pre-flight: a stale c2pool-dgb from a prior run holds the FIXED pool P2P +# listen port (5024, PREFIX-derived) -> ARM A aborts on "bind: Address already +# in use" and the gate FAILS as a phantom timeout. Free it before substrate up. +# DGB-only match; never touches other coins or the bitcoin-family regtest band. +kill_stale_c2pool() { + local pids; pids="$(pgrep -f 'c2pool-dgb .*--run' 2>/dev/null || true)" + [ -z "$pids" ] && return 0 + log "pre-flight: freeing pool P2P port from stale c2pool-dgb: $pids" + kill $pids 2>/dev/null || true; sleep 2 + pids="$(pgrep -f 'c2pool-dgb .*--run' 2>/dev/null || true)" + [ -n "$pids" ] && { kill -9 $pids 2>/dev/null || true; sleep 1; } + return 0 +} +kill_stale_c2pool + # --- substrate: two peered regtest nodes -------------------------------------- log "starting node A (regtest RPC $RPCPORT_A / P2P $P2PPORT_A)" "$DAEMON_BIN" -regtest -datadir="$DATADIR_A" -rpcport=$RPCPORT_A -port=$P2PPORT_A -daemon >/dev/null @@ -105,13 +120,21 @@ cli_a createwallet "$PAYOUT_LABEL" >/dev/null 2>&1 || cli_a loadwallet "$PAYOUT_ ADDR_A="$(cli_a getnewaddress "$PAYOUT_LABEL")" log "mining 101 maturity blocks to $ADDR_A" cli_a generatetoaddress 101 "$ADDR_A" >/dev/null +# Wait for node B to FULLY IBD-sync node A's chain before baselining. Otherwise +# BASE_B captures B mid-sync (e.g. 96 < A=101) and the ARM A won-detector +# (tip_b > BASE_B) fires on B's normal sync catch-up rather than on a genuine +# c2pool-relayed won block -- a false positive that defeats the gate. +HEIGHT_A="$(cli_a getblockcount)" +n=0; until [ "$(tip_b)" -ge "$HEIGHT_A" ]; do + n=$((n+1)); [ $n -gt 120 ] && die "node B never synced to A height $HEIGHT_A (got $(tip_b))"; sleep 0.5 +done BASE_B="$(tip_b)" # regtest magic + genesis are read from the running daemon's chainparams so the # embedded backend's getheaders bootstrap matches node A exactly (no hand-typed # consensus literal). DGB regtest magic is fixed in DigiByte Core chainparams. DGB_REGTEST_GENESIS="$(cli_a getblockhash 0)" -: "${DGB_REGTEST_MAGIC:?set DGB_REGTEST_MAGIC to the DigiByte regtest pchMessageStart hex (from chainparams)}" +: "${DGB_REGTEST_MAGIC:=fabfb5da}" # DGB regtest pchMessageStart (DigiByte Core chainparams); override for a custom regtest log "substrate OK: node A height $(cli_a getblockcount), peer B $BASE_B, genesis $DGB_REGTEST_GENESIS" # --- ARM A (P2P PRIMARY): live UpdateTip won block relayed to node B ---------- @@ -121,7 +144,8 @@ log "ARM A: launching embedded c2pool-dgb against node A (regtest diff -> natura --coin-rpc 127.0.0.1:$RPCPORT_A \ --coin-rpc-auth "$DATADIR_A/digibyte.conf" \ --coin-magic "$DGB_REGTEST_MAGIC" \ - --coin-genesis "$DGB_REGTEST_GENESIS" >/tmp/c2pool-dgb-soak.log 2>&1 & + --coin-genesis "$DGB_REGTEST_GENESIS" --regtest --regtest-force-won-share \ + --soak-regrind >/tmp/c2pool-dgb-soak.log 2>&1 & C2POOL_PID=$! log "c2pool-dgb PID $C2POOL_PID; waiting for a won share to relay a coinbase-only block to node B" n=0 @@ -130,16 +154,37 @@ until [ "$(tip_b)" -gt "$BASE_B" ]; do sleep 0.5 done WON_HASH="$(cli_b getblockhash "$(tip_b)")" -NTX="$(cli_b getblock "$WON_HASH" | grep -o '"tx"[^]]*]' | grep -o '"[0-9a-f]\{64\}"' | wc -l)" +NTX="$(cli_b getblock "$WON_HASH" | tr -d '\n' | grep -o '"tx"[^]]*]' | grep -o '"[0-9a-f]\{64\}"' | wc -l)" [ "$NTX" -eq 1 ] || die "ARM A: won block $WON_HASH has $NTX txs, expected 1 (coinbase-only, PROP 1)" log "ARM A OK: P2P-relayed coinbase-only won block $WON_HASH accepted by peer node B (PROP 1 + ARM A)" kill "$C2POOL_PID" >/dev/null 2>&1; C2POOL_PID="" -# --- ARM B (RPC FALLBACK): GATED on integrator isolation-mechanism decision ---- -# No --no-p2p-relay flag exists; isolating the submitblock fallback from the P2P arm -# is an open design call (see header item 2). Until integrator picks the mechanism, -# ARM B is NOT asserted -- shipping a placeholder assert would be the silent-gap the -# gate exists to prevent. -log "ARM B: GATED -- submitblock-fallback isolation mechanism pending integrator decision (header item 2)" +# --- ARM B (RPC FALLBACK, ISOLATED): submitblock-only via --no-p2p-relay ------- +# The --no-p2p-relay flag (main_dgb.cpp) suppresses the embedded P2P `block` +# relay sink, leaving the external-digibyted submitblock RPC fallback as the +# SOLE path to the network. A second forced won block reaching node B with the +# P2P arm muted proves ARM B reaches the network INDEPENDENTLY (header item 2, +# now resolved by the flag). +BASE_B2="$(tip_b)" +log "ARM B (isolated): relaunching c2pool-dgb with --no-p2p-relay (submitblock fallback only)" +"$C2POOL_DGB" --run \ + --coin-daemon 127.0.0.1:$P2PPORT_A \ + --coin-rpc 127.0.0.1:$RPCPORT_A \ + --coin-rpc-auth "$DATADIR_A/digibyte.conf" \ + --coin-magic "$DGB_REGTEST_MAGIC" \ + --coin-genesis "$DGB_REGTEST_GENESIS" --regtest --regtest-force-won-share \ + --soak-regrind --no-p2p-relay >/tmp/c2pool-dgb-soak-armb.log 2>&1 & +C2POOL_PID=$! +log "c2pool-dgb (ARM B) PID $C2POOL_PID; waiting for submitblock-only won block on node B" +n=0 +until [ "$(tip_b)" -gt "$BASE_B2" ]; do + n=$((n+1)); [ $n -gt 240 ] && die "ARM B: no won block reached node B within 120s (submitblock RPC fallback)" + sleep 0.5 +done +WON_HASH_B="$(cli_b getblockhash "$(tip_b)")" +NTX_B="$(cli_b getblock "$WON_HASH_B" | tr -d '\n' | grep -o '"tx"[^]]*]' | grep -o '"[0-9a-f]\{64\}"' | wc -l)" +[ "$NTX_B" -eq 1 ] || die "ARM B: won block $WON_HASH_B has $NTX_B txs, expected 1 (coinbase-only, PROP 1)" +log "ARM B OK: submitblock-RPC-only coinbase-only won block $WON_HASH_B accepted by peer node B (PROP 1 + ARM B, P2P arm suppressed)" +kill "$C2POOL_PID" >/dev/null 2>&1; C2POOL_PID="" -log "ARM A proven; ARM B + digibyted provisioning are the two remaining live-leg items for the #82 gate." +log "BOTH ARMS PROVEN: ARM A (P2P relay) block $WON_HASH + ARM B (submitblock isolated) block $WON_HASH_B each accepted by peer node B. #82 dual-path broadcaster gate satisfied (digibyted self-provisioned)." diff --git a/src/c2pool/main_dgb.cpp b/src/c2pool/main_dgb.cpp index 2dadf6858..fcc10c407 100644 --- a/src/c2pool/main_dgb.cpp +++ b/src/c2pool/main_dgb.cpp @@ -34,6 +34,8 @@ #include // make_mempool_tx_source (EmbeddedTxSource) #include #include // make_reconstruct_closure_from_template (#280) +#include // finalize_won_block_pow -- forced-won PoW grind JOINT (#82 gate) +#include // c2pool::dgb::compact_to_target (nBits -> u256) #include // won_share_inputs (#279) #include #include @@ -55,6 +57,7 @@ #include #include #include +#include #include #include #include @@ -86,13 +89,19 @@ void print_banner(const char* argv0, const core::CoinParams& p) std::cout << "c2pool-dgb " << C2POOL_VERSION << " — DigiByte Scrypt-only (V36)\n\n" << "Usage: " << argv0 - << " [--version] [--help] [--selftest] [--run] [--no-p2p-relay] [--stratum [H:]P]\n\n" + << " [--version] [--help] [--selftest] [--run] [--stratum [H:]P]\n" + << " [--coin-daemon H:P] [--coin-magic HEX] [--regtest]\n" + << " [--regtest-force-won-share] [--no-p2p-relay]\n\n" << "Status: pool/sharechain pillars live (Phase B); run-loop up\n" << " (--run: io_context + sharechain peer + Stratum standup).\n" << " --stratum [HOST:]PORT binds a miner-facing TCP listener\n" << " (e.g. --stratum 5022 or --stratum 127.0.0.1:5022); omit to\n" << " disable. Embedded P2P won-block relay + external digibyted\n" << " RPC fallback land in the next slices.\n" + << " --regtest-force-won-share (regtest-ONLY; requires\n" + << " --regtest AND --coin-daemon) drives ONE forced won\n" + << " share through the live dual-path broadcaster;\n" + << " --no-p2p-relay isolates the submitblock arm.\n" << "Network: " << network_summary(p) << "\n"; } @@ -143,7 +152,8 @@ int run_node(const core::CoinParams& params, bool testnet, const uint256& coin_genesis, const std::string& rpc_endpoint, const std::string& rpc_conf_path, - bool no_p2p_relay) + bool regtest = false, bool force_won_share = false, + bool no_p2p_relay = false) { io::io_context ioc; @@ -380,6 +390,46 @@ int run_node(const core::CoinParams& params, bool testnet, return {}; // coinbase-only today (see note above) }); + // -- #82 forced-won PoW finalize ------------------------------------- + // A forced-won share (the regtest --regtest-force-won-share live seam) + // carries no real Scrypt work, so the reconstructed block is daemon-rejected + // high-hash (observed: ProcessNewBlock AcceptBlock FAILED, proof of work + // failed). Grind the reconstructed header nonce to satisfy the block's OWN + // declared nBits via the won_block_finalize JOINT (over the #286 scrypt_pow + // SSOT) BEFORE dispatch, so the dual-path broadcaster relays a PoW-valid + // block the peer ACCEPTs. Gated to the regtest forced-won path ONLY: a + // genuine pool-won share already satisfies the parent target and is NEVER + // reground -- zero production behaviour change. + if (regtest && force_won_share) { + auto inner = std::move(faithful_reconstruct); + faithful_reconstruct = + [inner = std::move(inner)](const uint256& share_hash) + -> std::optional, std::string>> { + auto blk = inner(share_hash); + if (!blk || blk->first.size() < 80) return blk; // pass through / fail closed + // nBits is header bytes [72..75], little-endian. + const uint32_t nbits = + static_cast(blk->first[72]) + | (static_cast(blk->first[73]) << 8) + | (static_cast(blk->first[74]) << 16) + | (static_cast(blk->first[75]) << 24); + const auto target = c2pool::dgb::compact_to_target(nbits); + auto fin = dgb::coin::finalize_won_block_pow(blk->first, target); + if (!fin) { + std::cout << "[DGB-BLOCK] forced-won finalize: no satisfying nonce " + "for nBits=" << std::hex << nbits << std::dec + << " -- relaying UNGROUND (peer will reject high-hash)" + << std::endl; + return blk; + } + std::cout << "[DGB-BLOCK] forced-won finalize: ground nonce=" + << fin->grind.nonce << " after " << fin->grind.iters + << " iters (PoW now satisfies nBits=" << std::hex << nbits + << std::dec << ")" << std::endl; + return std::make_pair(std::move(fin->bytes), std::move(fin->hex)); + }; + } + p2p_node.tracker().m_on_block_found = dgb::coin::make_on_block_found( /*reconstruct=*/std::move(faithful_reconstruct), /*p2p_relay=*/[&ioc, &coin_p2p, no_p2p_relay](const std::vector& block_bytes) { @@ -571,6 +621,202 @@ int run_node(const core::CoinParams& params, bool testnet, std::cout << "[DGB] stratum disabled (no --stratum flag)" << std::endl; } + // ── #82 REGTEST-GATED forced-won-share LIVE seam (decision (a), 2026-06-21) ── + // + // Synthesize ONE won share, insert it into the LIVE dgb::ShareTracker chain, + // and fire m_on_block_found(share_hash) AFTER both broadcast arms are bound + // (above). This drives the REAL faithful reconstruct closure + // (make_reconstruct_closure_from_template) + the REAL dual-path dispatch + // (make_on_block_found) over a genuine tracker share — the live counterpart + // of the dgb_forced_won_share_dualpath KAT, whose seams are synthetic. + // + // FAIL-CLOSED GATE: this seam is REACHABLE ONLY on a regtest run. There is + // no regtest CoinParam (the run builds make_coin_params(testnet=false)), so + // "regtest" is asserted conservatively as BOTH --regtest AND a --coin-daemon + // target (a dev regtest digibyted, e.g. magic fabfb5da). If --regtest-force- + // won-share is passed WITHOUT that, it is REFUSED (logged, not fired): it can + // never run on a production/mainnet path. The share is posted on a short + // timer so it fires after the producer handshake driver, on the io thread. + io::steady_timer force_won_timer(ioc); + if (force_won_share) { + const bool gated_regtest = regtest && !coin_daemon.empty(); + if (!gated_regtest) { + std::cout << "[DGB] REFUSED --regtest-force-won-share: not a regtest " + "run (requires BOTH --regtest AND --coin-daemon ). Seam NOT fired — fail-closed." << std::endl; + } else { + // Build a v36 MergedMiningShare with a NULL parent so the PPLNS walk + // is skipped entirely (no chain ancestry needed) — payouts empty, + // donation == subsidy. The reconstruct closure reads this share via + // chain.get_share(h): won_share_inputs(*obj) (m_min_header + + // m_merkle_link, both default-constructed here) and + // generate_share_transaction(*obj, ...) (the SSOT gentx regen). The + // share is inserted under its m_hash so the get_share lookup hits. + auto* forced = new dgb::MergedMiningShare(); + // Deterministic synthetic share hash (all-hex; no real PoW). The + // tag "f02ced" marks it as the #82 forced-won-share seam in logs. + forced->m_hash.SetHex( + "00000000000000000000000000000000000000000000000000000000f02ced01"); + forced->m_prev_hash.SetNull(); // null parent => PPLNS skipped + forced->m_coinbase.m_data = + { 0x03, 0xa1, 0xb2, 0xc3, 0x04, 0x11, 0x22, 0x33, 0x44 }; + forced->m_nonce = 0x12345678; + forced->m_pubkey_hash.SetNull(); + forced->m_pubkey_type = 0; + forced->m_subsidy = 500000000ull; + forced->m_donation = 0; + forced->m_stale_info = dgb::none; + forced->m_desired_version = 36; + forced->m_segwit_data = std::nullopt; + forced->m_far_share_hash.SetNull(); + forced->m_max_bits = 0x1e0fffff; + forced->m_bits = 0x1e0fffff; + forced->m_timestamp = 1718700000; + forced->m_absheight = 1000; + forced->m_merged_payout_hash.SetNull(); + forced->m_last_txout_nonce = 0x0001020304050607ull; + // m_min_header / m_merkle_link / m_ref_merkle_link default-construct. + const uint256 forced_hash = forced->m_hash; + p2p_node.tracker().add(forced); // chain indexes by m_hash + + // ── Fire AFTER work-template/UpdateTip-ready, not a blind one-shot ── + // + // The historical 0-shares/120s timeout had a SECOND cause behind the + // stale port-5024 collision: a fixed 5s one-shot fired the won-share + // BEFORE the embedded coin-daemon P2P producer finished its + // version/verack handshake and before any header reached HeaderChain + // (the first UpdateTip). A won block dispatched then cannot reach + // node B by the P2P-relay arm (peer not yet connected) and races + // ahead of node B's own sync, so neither path produced a + // ProcessNewBlock ACCEPT and the soak grep saw "Final share count: 0". + // + // Gate the fire on a genuine readiness signal instead (integrator + // 2026-06-21): poll until BOTH (a) the coin-daemon P2P handshake is + // complete (node B reachable for the P2P-relay arm + the submitblock + // RPC fallback) AND (b) HeaderChain::tip_hash() carries a tip (>=1 + // UpdateTip ingested -> a work template with a real + // previousblockhash now exists). Latch-once. A bounded fallback fires + // after the cap with a LOUD, attributable log so a slow/stuck peer + // surfaces as a named blocker, not a silent no-fire. Regtest-only; + // zero production-path behaviour change. + auto fw_attempt = std::make_shared(0); + constexpr int FW_MAX_ATTEMPTS = 40; // 40 * 1.5s = 60s cap + auto fw_poll = std::make_shared< + std::function>(); + // -- #82 faithful seed: rebuild the synthetic won share header + // from node B's LIVE getblocktemplate (integrator 2026-06-21) -- + // Bare prevhash seeding fixes only bad-prevblk; node B can still + // reject on a wrong nBits (high-hash), a mismatched BIP34 height + // (bad-cb-height) or an over-claimed coinbase (bad-cb-amount). + // Pulling node B's OWN Scrypt-lane GBT (make_gbt_request pins + // algo=scrypt) yields a fully connectable coinbase-only block in + // one move: correct prev + bits + height + coinbasevalue. The + // reconstruct closure frames it; --soak-regrind then grinds the + // nonce to satisfy THIS bits. Regtest-only; coinbase-only => + // merkle root == gentx txid by construction (no bad-txnmrklroot). + auto seed_forced_from_gbt = [forced, &rpc, &header_chain]() { + if (!rpc) { + // Unreachable on the gated path (--regtest-force-won-share + // requires --coin-daemon, which arms rpc); without the GBT + // there is no authoritative bits/height anyway, so the block + // cannot connect. Surface, do not fabricate. + std::cout << "[DGB] #82 forced-won seed: no RPC arm - cannot " + "pull node-B GBT; block will not connect" + << std::endl; + return; + } + try { + auto gbt = rpc->getblocktemplate({"segwit"}); + auto& mh = forced->m_min_header; + mh.m_version = gbt.at("version").get(); + mh.m_previous_block.SetHex( + gbt.at("previousblockhash").get()); + mh.m_bits = static_cast( + std::stoul(gbt.at("bits").get(), nullptr, 16)); + mh.m_timestamp = gbt.at("curtime").get(); + forced->m_bits = mh.m_bits; + forced->m_max_bits = mh.m_bits; + forced->m_timestamp = mh.m_timestamp; + forced->m_absheight = gbt.at("height").get(); + forced->m_subsidy = gbt.at("coinbasevalue").get(); + // BIP34: the coinbase scriptSig MUST begin with the + // serialized block height (minimal CScriptNum push) or + // node B rejects bad-cb-height. The synthetic + // m_coinbase.m_data is passed through verbatim by + // generate_share_transaction, so encode node B's GBT + // height here. height>0 always (won block is tip+1). + { + uint32_t h = forced->m_absheight; + std::vector hb; + while (h) { hb.push_back(h & 0xff); h >>= 8; } + if (hb.empty()) hb.push_back(0); + if (hb.back() & 0x80) hb.push_back(0); + std::vector cb; + cb.push_back(static_cast(hb.size())); + cb.insert(cb.end(), hb.begin(), hb.end()); + const unsigned char tail[] = {0x04, 0x11, 0x22, 0x33, 0x44}; + cb.insert(cb.end(), std::begin(tail), std::end(tail)); + forced->m_coinbase.m_data = cb; + } + std::cout << "[DGB] #82 forced-won seed from node-B GBT: height=" + << forced->m_absheight << " prev=" + << gbt.at("previousblockhash").get().substr(0, 16) + << " bits=" << gbt.at("bits").get() + << " coinbasevalue=" << forced->m_subsidy << std::endl; + } catch (const std::exception& e) { + std::cout << "[DGB] #82 forced-won seed: getblocktemplate FAILED (" + << e.what() + << ") - NAMED BLOCKER, block will not connect" + << std::endl; + } + }; + auto fire_forced = + [&p2p_node, forced_hash, no_p2p_relay, seed_forced_from_gbt](const char* why) { + seed_forced_from_gbt(); // node-B-faithful header before reconstruct + std::cout << "[DGB] #82 forced-won-share seam firing " + << "m_on_block_found(" + << forced_hash.GetHex().substr(0, 16) + << ") — regtest, " << why << "; " + << (no_p2p_relay ? "ARM B (submitblock) ISOLATED " + "(--no-p2p-relay)" + : "BOTH arms (P2P relay + submitblock)") + << std::endl; + auto& cb = p2p_node.tracker().m_on_block_found; + if (cb) cb(forced_hash); + else + std::cout << "[DGB] forced-won-share: m_on_block_found " + "UNBOUND — nothing fired" << std::endl; + }; + *fw_poll = [&force_won_timer, &coin_p2p, &header_chain, fw_poll, + fw_attempt, fire_forced] + (const boost::system::error_code& ec) { + if (ec) return; + const bool handshake_ready = + coin_p2p && coin_p2p->is_handshake_complete(); + const bool tip_ready = header_chain.tip_hash().has_value(); + if (handshake_ready && tip_ready) { + fire_forced("work-template/UpdateTip-ready " + "(handshake complete + tip present)"); + return; + } + if (++(*fw_attempt) >= FW_MAX_ATTEMPTS) { + std::cout << "[DGB] forced-won-share readiness TIMEOUT after " + << FW_MAX_ATTEMPTS << " polls — unmet: " + << (handshake_ready ? "" : "[handshake] ") + << (tip_ready ? "" : "[tip] ") + << "— firing anyway (last-resort; node B may reject)" + << std::endl; + fire_forced("readiness-TIMEOUT last-resort"); + return; + } + force_won_timer.expires_after(std::chrono::milliseconds(1500)); + force_won_timer.async_wait(*fw_poll); + }; + force_won_timer.expires_after(std::chrono::milliseconds(1500)); + force_won_timer.async_wait(*fw_poll); + } + } + std::cout << "[DGB] run-loop up: " << network_summary(params) << "\n"; std::cout << "[DGB] io_context running. Ctrl-C to stop." << std::endl; @@ -600,7 +846,19 @@ int main(int argc, char** argv) uint256 coin_genesis; // --coin-genesis HASH (initial getheaders locator base) std::string rpc_endpoint; // --coin-rpc HOST:PORT (external digibyted submit arm) std::string rpc_conf_path; // --coin-rpc-auth PATH to digibyte.conf (creds source) - bool no_p2p_relay = false; // --no-p2p-relay: suppress embedded P2P-relay arm (#82 ARM B isolation) + // ── #82 regtest-gated forced-won-share LIVE seam (decision (a), 2026-06-21) ── + // --regtest-force-won-share synthesizes ONE won share into the live + // ShareTracker and fires m_on_block_found AFTER both broadcast arms are + // bound, driving the faithful reconstruct closure + dual-path dispatch live. + // FAIL-CLOSED: it is REFUSED unless the run is regtest. There is no regtest + // CoinParam yet (make_coin_params(testnet=false)), so 'regtest' here means an + // explicit --regtest marker AND a --coin-daemon target (a dev regtest + // digibyted). Absent either, the flag is logged-and-ignored — it can NEVER + // fire on a production/mainnet run path. --no-p2p-relay isolates ARM B + // (external submitblock) by suppressing the embedded P2P-relay sink. + bool regtest = false; // --regtest (dev/regtest marker; gates the forced seam) + bool force_won_share = false; // --regtest-force-won-share (regtest-ONLY won-block live seam) + bool no_p2p_relay = false; // --no-p2p-relay (suppress ARM A to isolate ARM B) for (int i = 1; i < argc; ++i) { if (std::strcmp(argv[i], "--version") == 0) { std::cout << "c2pool-dgb " << C2POOL_VERSION << "\n"; @@ -635,6 +893,9 @@ int main(int argc, char** argv) if (std::strcmp(argv[i], "--coin-rpc-auth") == 0 && i + 1 < argc) { rpc_conf_path = argv[++i]; // path to digibyte.conf (rpcpassword stays in-file) } + if (std::strcmp(argv[i], "--regtest") == 0) regtest = true; + if (std::strcmp(argv[i], "--regtest-force-won-share") == 0) + force_won_share = true; // gated below: regtest-ONLY if (std::strcmp(argv[i], "--no-p2p-relay") == 0) no_p2p_relay = true; } @@ -648,7 +909,8 @@ int main(int argc, char** argv) if (want_run) return run_node(params, /*testnet=*/false, stratum_addr, stratum_port, coin_daemon, coin_magic, coin_genesis, - rpc_endpoint, rpc_conf_path, no_p2p_relay); + rpc_endpoint, rpc_conf_path, + regtest, force_won_share, no_p2p_relay); // --selftest, or a bare invocation: drive the live score path so the // binary exercises real consensus code, then exit cleanly. diff --git a/src/impl/dgb/coin/won_block_finalize.hpp b/src/impl/dgb/coin/won_block_finalize.hpp new file mode 100644 index 000000000..0fbc6ea75 --- /dev/null +++ b/src/impl/dgb/coin/won_block_finalize.hpp @@ -0,0 +1,118 @@ +#pragma once +// --------------------------------------------------------------------------- +// dgb::coin::finalize_won_block_pow -- the grind->reconstruct->submit JOINT +// (#82 gate). It is the missing composition between the faithful won-block +// RECONSTRUCTION (reconstruct_won_block*.hpp, leg-2) and a node-B +// ProcessNewBlock ACCEPT: the A/B delivery proof reached node B but was +// consensus-rejected "high-hash, proof of work failed" because the forced-won +// seam carried no real PoW. This seam grinds the reconstructed block's header +// nonce until the DGB-Scrypt digest satisfies the parent target, then hands the +// finalized {bytes, hex} to the existing dual-path broadcaster (P2P relay arm + +// submitblock RPC fallback) unchanged. +// +// ORDERING (integrator-pinned, 2026-06-21): merkle_root is set FIRST (it is +// already baked into bytes[36..67] by the reconstructor / assemble_won_block), +// THEN the nonce at bytes[76..79] is ground -- scrypt hashes the full 80 bytes, +// so grinding before the merkle was fixed would invalidate the found nonce. +// This seam runs strictly AFTER reconstruction, so that invariant holds by +// construction. +// +// Byte layout: the reconstructor frames the canonical block as +// header(80) | tx_count(CompactSize) | [gentx] ++ other_txs +// where header = version(4)|prev(32)|merkle(32)|time(4)|bits(4)|nonce(4). +// bytes[0..79] is therefore the exact 80-byte header node B re-hashes, and the +// nonce is bytes[76..79] little-endian (== nonce_grinder.hpp kHeaderNonceOffset). +// +// SSOT call-through: the PoW is computed ONLY via grind_won_nonce -> +// scrypt_pow_hash (the #286 digest CALL SSOT), the EXACT pow<=target gate +// header_chain.hpp runs -- so a finalized block this seam emits is, by +// construction, one node B's own Scrypt validation accepts. +// +// FAIL-CLOSED (mirrors make_reconstruct_closure): returns std::nullopt -- never +// a partial/wrong block -- when the input is too short to frame a header or no +// satisfying nonce is found inside max_iters. A header that does not satisfy its +// target is daemon-rejected anyway, so emitting nothing is strictly safer. +// +// Standalone-guard discipline: depends ONLY on nonce_grinder.hpp (real btclibs +// scrypt + header-only u256). NO core, NO dgb OBJECT lib, NO util/HexStr -- the +// RPC-fallback hex is regenerated with a local lowercase encoder byte-identical +// to HexStr, so the seam (and its KAT) link exactly like the grinder. Operates +// on raw {bytes} so it pulls none of the block_assembly/reconstruct include set. +// +// Per-coin isolation: src/impl/dgb/ only. p2pool-merged-v36 surface: NONE. +// DGB-Scrypt is a STANDALONE parent in the V36 default build. +// --------------------------------------------------------------------------- + +#include +#include +#include +#include +#include +#include +#include + +#include // grind_won_nonce, GrindOutcome, u256, kHeaderNonceOffset + +namespace dgb::coin { + +// A reconstructed parent block whose header nonce now satisfies the parent +// target -- ready for the dual-path broadcaster: +// bytes : the blob the embedded P2P relay sends +// hex : the same block for the external submitblock (RPC) fallback +// grind : the winning nonce + its pow_hash + iteration count (audit trail) +struct FinalizedWonBlock { + std::vector bytes; + std::string hex; + GrindOutcome grind; +}; + +// Lowercase hex, byte-identical to bitcoin util HexStr -- kept local so this +// seam stays in the grinder's no-core / no-util standalone lineage. +inline std::string finalize_block_to_hex(const std::vector& v) { + static constexpr char kHex[] = "0123456789abcdef"; + std::string s; + s.reserve(v.size() * 2); + for (unsigned char b : v) { + s.push_back(kHex[(b >> 4) & 0xf]); + s.push_back(kHex[b & 0xf]); + } + return s; +} + +// Grind the reconstructed block to a node-acceptable PoW. +// +// block_bytes : a fully reconstructed block (header at [0..79], merkle_root +// ALREADY set); the seam owns header[76..79] only. +// target : compact_to_target(nBits) for the parent -- the SAME u256 +// header_chain.hpp / the grinder KAT compare against. +// start_nonce / max_iters : forwarded to grind_won_nonce. +// +// Returns {bytes, hex, grind} with hex == finalize_block_to_hex(bytes) and the +// winning nonce spliced into bytes[76..79] LE. Returns std::nullopt (fail- +// closed) if block_bytes is too short to frame an 80-byte header or no nonce +// satisfies the target within max_iters. +inline std::optional +finalize_won_block_pow(std::vector block_bytes, const u256& target, + uint32_t start_nonce = 0, + uint64_t max_iters = (uint64_t{1} << 32)) +{ + if (block_bytes.size() < 80) + return std::nullopt; // too short to frame a header -> fail closed + + std::array header{}; + std::copy(block_bytes.begin(), block_bytes.begin() + 80, header.begin()); + + auto out = grind_won_nonce(header, target, start_nonce, max_iters); + if (!out.has_value()) + return std::nullopt; // no satisfying nonce in budget -> fail closed + + // Splice the winning nonce (grinder wrote it LE into header[76..79]) back + // into the block; only the 4 nonce bytes change, every tx byte is untouched. + std::copy(header.begin() + kHeaderNonceOffset, header.begin() + 80, + block_bytes.begin() + kHeaderNonceOffset); + + std::string hex = finalize_block_to_hex(block_bytes); + return FinalizedWonBlock{std::move(block_bytes), std::move(hex), *out}; +} + +} // namespace dgb::coin diff --git a/src/impl/dgb/test/CMakeLists.txt b/src/impl/dgb/test/CMakeLists.txt index 5e9eb0d6e..ddf1ae443 100644 --- a/src/impl/dgb/test/CMakeLists.txt +++ b/src/impl/dgb/test/CMakeLists.txt @@ -388,4 +388,22 @@ if (BUILD_TESTING AND GTest_FOUND) target_link_libraries(dgb_regrind_block_test PRIVATE GTest::gtest_main GTest::gtest) gtest_add_tests(dgb_regrind_block_test "" AUTO) + # --- #82 gate: grind->reconstruct->submit JOINT (coin/won_block_finalize.hpp) + # Pins finalize_won_block_pow: takes a faithfully reconstructed block (header + # at [0..79], merkle_root already set -- merkle FIRST) and grinds the nonce + # at [76..79] THROUGH grind_won_nonce -> scrypt_pow_hash (#286 SSOT) until the + # DGB-Scrypt PoW satisfies the parent target, splicing the winning nonce back + # and regenerating the RPC-fallback hex in lockstep with the P2P-arm bytes. + # Asserts: finalized header pow<=target via the same SSOT; nonce spliced LE + # into [76..79]; hex == bytes; ONLY the 4 nonce bytes mutate (merkle + tx tail + # untouched); fail-closed (nullopt) on a sub-80-byte input. Reuses the SAME + # _dgb_scrypt_tus real-scrypt set as dgb_nonce_grinder_test -- header-only + # u256, no core / no dgb OBJECT lib. Same standalone-guard discipline; MUST + # stay in BOTH build.yml --target allowlists (#143 trap). + add_executable(dgb_won_block_finalize_test won_block_finalize_test.cpp ${_dgb_scrypt_tus}) + target_include_directories(dgb_won_block_finalize_test PRIVATE ${CMAKE_SOURCE_DIR}/src/btclibs) + target_link_libraries(dgb_won_block_finalize_test PRIVATE + GTest::gtest_main GTest::gtest) + gtest_add_tests(dgb_won_block_finalize_test "" AUTO) + endif() diff --git a/src/impl/dgb/test/won_block_finalize_test.cpp b/src/impl/dgb/test/won_block_finalize_test.cpp new file mode 100644 index 000000000..3dd59f16d --- /dev/null +++ b/src/impl/dgb/test/won_block_finalize_test.cpp @@ -0,0 +1,124 @@ +// DGB won-block finalize joint (coin/won_block_finalize.hpp) -- #82 gate. +// +// Pins finalize_won_block_pow: the grind->reconstruct->submit composition that +// turns a faithfully RECONSTRUCTED parent block (header[0..79] with merkle_root +// already set; merkle FIRST, integrator-pinned ordering) into one whose header +// nonce satisfies the parent target -- the missing link between leg-2's +// reconstruction and a node-B ProcessNewBlock ACCEPT. The PoW is computed ONLY +// through grind_won_nonce -> scrypt_pow_hash (the #286 digest CALL SSOT), so a +// finalized block is, by construction, one node B's own Scrypt validation +// accepts. This is the unit floor directly under the live tip-extension gate. +// +// Real scrypt (the _dgb_scrypt_tus set), header-only u256 -- the same no-core / +// no-OBJECT-lib standalone-guard discipline as nonce_grinder_test / scrypt_pow_test. + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +using dgb::coin::u256; +using dgb::coin::scrypt_pow_hash; +using dgb::coin::kHeaderNonceOffset; +using dgb::coin::finalize_won_block_pow; +using dgb::coin::finalize_block_to_hex; + +namespace { + +// A synthetic reconstructed block: a deterministic 80-byte header (merkle_root +// already baked in -- finalize never touches it) followed by a short tx tail +// standing in for tx_count|[gentx]++other_txs. finalize owns header[76..79] only. +std::vector synthetic_block(std::size_t tail_len = 37) { + std::vector b; + b.reserve(80 + tail_len); + for (int i = 0; i < 80; ++i) + b.push_back(static_cast((i * 13 + 5) % 251)); + for (std::size_t i = 0; i < tail_len; ++i) + b.push_back(static_cast((i * 7 + 3) % 256)); + return b; +} + +// Target accepting ~1/4 of uniform digests (same shape as nonce_grinder_test): +// terminates in ~4 iters, so the budget below is never a factor. +u256 easy_quarter_target() { + u256 t; + t.limb[0] = 0xffffffffffffffffULL; + t.limb[1] = 0xffffffffffffffffULL; + t.limb[2] = 0xffffffffffffffffULL; + t.limb[3] = 0x3fffffffffffffffULL; + return t; +} + +// The 80-byte header sitting at the front of a (finalized) block. +std::array header_of(const std::vector& block) { + std::array h{}; + std::copy(block.begin(), block.begin() + 80, h.begin()); + return h; +} + +uint32_t read_nonce_le(const std::vector& block) { + return (uint32_t)block[kHeaderNonceOffset + 0] + | ((uint32_t)block[kHeaderNonceOffset + 1] << 8) + | ((uint32_t)block[kHeaderNonceOffset + 2] << 16) + | ((uint32_t)block[kHeaderNonceOffset + 3] << 24); +} + +constexpr uint64_t kTestBudget = uint64_t{1} << 20; // vs ~4 expected + +// --- finalize yields a block whose header satisfies the parent target ------- +TEST(DgbWonBlockFinalizeKAT, FinalizesToSatisfyingPoW) { + u256 target = easy_quarter_target(); + auto out = finalize_won_block_pow(synthetic_block(), target, 0, kTestBudget); + ASSERT_TRUE(out.has_value()); + // Re-hash the finalized header through the SAME #286 SSOT. + u256 pow = scrypt_pow_hash(header_of(out->bytes)); + EXPECT_FALSE(pow > target); // pow <= target -> valid PoW + EXPECT_TRUE(pow == out->grind.pow_hash); // reported digest == SSOT (no bypass) + EXPECT_GE(out->grind.iters, uint64_t{1}); +} + +// --- the winning nonce is spliced into block bytes[76..79] little-endian ---- +TEST(DgbWonBlockFinalizeKAT, SplicesWinningNonceLittleEndian) { + auto out = finalize_won_block_pow(synthetic_block(), easy_quarter_target(), 0, kTestBudget); + ASSERT_TRUE(out.has_value()); + EXPECT_EQ(read_nonce_le(out->bytes), out->grind.nonce); +} + +// --- RPC-fallback hex stays in lockstep with the P2P-arm bytes -------------- +TEST(DgbWonBlockFinalizeKAT, HexMatchesFinalizedBytes) { + auto out = finalize_won_block_pow(synthetic_block(), easy_quarter_target(), 0, kTestBudget); + ASSERT_TRUE(out.has_value()); + EXPECT_EQ(out->hex.size(), out->bytes.size() * 2); + EXPECT_EQ(out->hex, finalize_block_to_hex(out->bytes)); +} + +// --- ONLY the 4 nonce bytes change: merkle_root + every tx byte untouched --- +TEST(DgbWonBlockFinalizeKAT, OnlyNonceBytesMutated) { + auto in = synthetic_block(); + auto out = finalize_won_block_pow(in, easy_quarter_target(), 0, kTestBudget); + ASSERT_TRUE(out.has_value()); + ASSERT_EQ(out->bytes.size(), in.size()); + // header[0..75] (version|prev|merkle|time|bits) identical. + for (std::size_t i = 0; i < kHeaderNonceOffset; ++i) + EXPECT_EQ(out->bytes[i], in[i]) << "prefix byte " << i << " changed"; + // tx tail [80..] identical. + for (std::size_t i = 80; i < in.size(); ++i) + EXPECT_EQ(out->bytes[i], in[i]) << "tail byte " << i << " changed"; +} + +// --- fail-closed: too short to frame an 80-byte header -> nullopt ----------- +TEST(DgbWonBlockFinalizeKAT, FailsClosedOnShortInput) { + std::vector shortb(79, 0x11); + auto out = finalize_won_block_pow(shortb, easy_quarter_target(), 0, kTestBudget); + EXPECT_FALSE(out.has_value()); +} + +} // namespace