Skip to content

Commit fb89281

Browse files
authored
dash(node): tx-forwarding known-tx retention — fix p2pool-dash join convergence (#834)
Fix the DASH sharechain join-convergence bug: c2pool was disconnected by canonical p2pool-dash for 'referenced unknown transaction' — register_template_txs evicted the previous template's txs wholesale every rotation, so a stale-job re-announced share could no longer forward its referenced txs. Now retains a rolling window of distinct template tx-sets (template-identity dedup so ~50 payout scripts consume one slot), declines a mint whose template txs left the window (by-construction: every minted share is fully forwardable; a won BLOCK is never affected — mutually-exclusive branch), sends remember_tx bytes before shares, marks shares broadcast only after reaching a peer. DASH-only, consensus/reward-neutral. Fable-approved (both blockers refuted); 6/6 retention KATs.
1 parent 66e0167 commit fb89281

5 files changed

Lines changed: 415 additions & 23 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
//
3+
// Pure, unit-testable bookkeeping for the known-transaction pool that backs
4+
// share broadcast (remember_tx forwarding). Factored out of NodeImpl so the
5+
// retention/eviction semantics can be exercised without a live node.
6+
//
7+
// Two concerns:
8+
// retain_template_txs() — the rolling-window retention with template-identity
9+
// dedup (code review F1): register_template_txs is invoked per producer-job
10+
// cache MISS, i.e. per (prev_share_hash, payout_script), NOT per template
11+
// rotation. A ~50-payout-script cluster therefore re-registers the SAME
12+
// template ~50 times on one tip; pushing each unconditionally collapses the
13+
// N-slot window to a single template within seconds. Dedup by the template's
14+
// tx-hash SET (its identity): a set already retained refreshes recency and
15+
// consumes NO new slot, so N re-registrations of one template use ONE slot
16+
// and the window holds N DISTINCT templates as intended.
17+
//
18+
// all_txs_backable() — the canonical broadcast gate (F3): a share may be sent
19+
// only when we HOLD the bytes of every referenced new-tx (so remember_tx can
20+
// always forward them). This is what makes the "referenced unknown
21+
// transaction" disconnect unreachable by construction, and — paired with the
22+
// mint-time decline of shares whose txs are not retained — makes "every share
23+
// we mint/broadcast is backable" a by-construction invariant.
24+
25+
#pragma once
26+
27+
#include <cstddef>
28+
#include <deque>
29+
#include <set>
30+
#include <vector>
31+
32+
#include <core/uint256.hpp>
33+
34+
namespace dash {
35+
36+
// Retain the tx set of a newly-registered template in a rolling window of the
37+
// last `cap` DISTINCT templates, inserting its tx bytes into `known_txs`.
38+
//
39+
// recent_sets : rolling history of distinct template tx-hash sets
40+
// (front = oldest, back = newest).
41+
// known_txs : uint256 -> tx map; needs insert_or_assign(k,v) and erase(k).
42+
// hashes/txs : parallel arrays of the template's tx hashes and bytes.
43+
// cap : number of DISTINCT templates to retain.
44+
//
45+
// A tx is erased from `known_txs` only once it has fallen out of EVERY retained
46+
// set. F1 dedup: if this template's tx set is already retained, its recency is
47+
// refreshed (moved to back) and no new slot is consumed.
48+
template <typename TxMap, typename Tx>
49+
inline void retain_template_txs(std::deque<std::set<uint256>>& recent_sets,
50+
TxMap& known_txs,
51+
const std::vector<uint256>& hashes,
52+
const std::vector<Tx>& txs,
53+
std::size_t cap)
54+
{
55+
if (cap == 0)
56+
return;
57+
58+
std::set<uint256> new_set(hashes.begin(), hashes.end());
59+
60+
// F1 dedup by template identity (the tx-hash set). If this template is
61+
// already retained, refresh its recency (move to back) — no new slot, no
62+
// re-eviction. This is what stops ~50 payout-script re-registrations of one
63+
// template from evicting the rest of the window.
64+
for (auto it = recent_sets.begin(); it != recent_sets.end(); ++it) {
65+
if (*it == new_set) {
66+
if (std::next(it) != recent_sets.end()) {
67+
std::set<uint256> refreshed = std::move(*it);
68+
recent_sets.erase(it);
69+
recent_sets.push_back(std::move(refreshed));
70+
}
71+
return;
72+
}
73+
}
74+
75+
// Distinct template: insert its tx bytes and consume a slot.
76+
const std::size_t n = std::min(hashes.size(), txs.size());
77+
for (std::size_t i = 0; i < n; ++i)
78+
known_txs.insert_or_assign(hashes[i], txs[i]);
79+
recent_sets.push_back(std::move(new_set));
80+
81+
// Evict the oldest slots beyond `cap`; a tx is removed from `known_txs` only
82+
// once it is absent from EVERY remaining retained set.
83+
while (recent_sets.size() > cap) {
84+
const std::set<uint256> evicted = std::move(recent_sets.front());
85+
recent_sets.pop_front();
86+
for (const auto& h : evicted) {
87+
bool still_retained = false;
88+
for (const auto& s : recent_sets) {
89+
if (s.count(h)) { still_retained = true; break; }
90+
}
91+
if (!still_retained)
92+
known_txs.erase(h);
93+
}
94+
}
95+
}
96+
97+
// True iff every hash in `tx_hashes` is present in `held` (the bytes we hold,
98+
// e.g. m_known_txs). The canonical broadcast gate: a share is backable — safe to
99+
// send without risking the peer's "referenced unknown transaction" disconnect —
100+
// only when we can forward every referenced tx.
101+
template <typename Held>
102+
inline bool all_txs_backable(const std::vector<uint256>& tx_hashes,
103+
const Held& held)
104+
{
105+
for (const auto& h : tx_hashes)
106+
if (held.find(h) == held.end())
107+
return false;
108+
return true;
109+
}
110+
111+
} // namespace dash

src/impl/dash/node.cpp

Lines changed: 123 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
#include "node.hpp"
33
#include "share.hpp"
44
#include "mint_runloop.hpp" // dash::mint::elect_best_share (election policy SSOT)
5+
#include "known_txs_retention.hpp" // dash::retain_template_txs / all_txs_backable (F1/F3)
56

67
#include <core/uint256.hpp>
78
#include <core/common.hpp>
@@ -1140,27 +1141,43 @@ void NodeImpl::broadcast_share(const uint256& share_hash)
11401141
if (!m_chain->contains(share_hash))
11411142
return;
11421143

1143-
// Walk back up to 5 shares, collecting the not-yet-broadcast ones.
1144+
// Walk back up to 5 shares, collecting the not-yet-broadcast ones. code
1145+
// review F2: do NOT mark them shared here — the tx-completeness gate in
1146+
// send_shares can skip a share for ALL peers (unbacked now, or zero peers),
1147+
// and marking before the send would leave a skipped chain TIP permanently
1148+
// "already shared" and never re-pushed → silent PPLNS-credit loss. We mark
1149+
// only what actually reached >= 1 peer, below.
11441150
std::vector<uint256> to_send;
11451151
int32_t height = m_chain->get_height(share_hash);
11461152
int32_t walk = std::min(height, 5);
11471153
for (auto [hash, data] : m_chain->get_chain(share_hash, walk)) {
11481154
if (m_shared_share_hashes.count(hash))
11491155
break;
1150-
m_shared_share_hashes.insert(hash);
11511156
to_send.push_back(hash);
11521157
}
11531158
if (to_send.empty())
11541159
return;
11551160

1156-
for (auto& [nonce, peer] : m_peers)
1157-
send_shares(peer, to_send);
1161+
std::set<uint256> actually_sent;
1162+
for (auto& [nonce, peer] : m_peers) {
1163+
auto sent = send_shares(peer, to_send);
1164+
actually_sent.insert(sent.begin(), sent.end());
1165+
}
1166+
// Mark ONLY shares that reached a peer; anything skipped for every peer (or
1167+
// with no peers connected) stays un-marked and is retried on the next
1168+
// broadcast — once its txs are retained or a peer appears.
1169+
for (const auto& h : actually_sent)
1170+
m_shared_share_hashes.insert(h);
11581171
}
11591172

1160-
void NodeImpl::send_shares(peer_ptr peer, const std::vector<uint256>& share_hashes)
1173+
std::vector<uint256> NodeImpl::send_shares(peer_ptr peer,
1174+
const std::vector<uint256>& share_hashes)
11611175
{
11621176
// Caller (broadcast_share) already holds a shared tracker lock; this
11631177
// method only READS chain + m_known_txs and writes to the peer socket.
1178+
// Returns the hashes of the shares actually SENT to this peer (F2): the
1179+
// caller marks only those as broadcast, so a share skipped for all peers is
1180+
// retried rather than silently withheld.
11641181
std::vector<ShareType> shares;
11651182
for (const auto& hash : share_hashes) {
11661183
if (!m_chain->contains(hash))
@@ -1171,7 +1188,58 @@ void NodeImpl::send_shares(peer_ptr peer, const std::vector<uint256>& share_hash
11711188
}
11721189
}
11731190
if (shares.empty())
1174-
return;
1191+
return {};
1192+
1193+
// ── Tx-completeness gate (canonical p2pool p2p.py:404 parity) ───────────
1194+
// A v13+ share references its new transactions by hash
1195+
// (new_transaction_hashes); a canonical peer that cannot resolve those txs
1196+
// DISCONNECTS us with "referenced unknown transaction" (p2p.py:403-404,
1197+
// handle_shares / handle_remember_tx). We re-announce the best chain's head
1198+
// and up to 5 ancestors (ROOT-2 re-announce, broadcast_share), and shares
1199+
// pulled via sharereply arrive WITHOUT their txs (the reply carries no tx
1200+
// bytes), so their tx bytes are absent from m_known_txs. Sending such a
1201+
// share unbacked is exactly what makes the public canonical seeds drop the
1202+
// connection ~300ms after handshake — the isolation root cause.
1203+
//
1204+
// So only broadcast a share when we HOLD the bytes of EVERY referenced new
1205+
// tx (m_known_txs), so remember_tx below can always forward them. code
1206+
// review F3: gating on m_remote_txs (the peer's have_tx advert) alone is
1207+
// weaker than canonical — that advert can be stale (the peer may have dropped
1208+
// the tx), and sending hash-only for a tx the peer no longer holds is exactly
1209+
// the disconnect. Requiring the bytes is the canonical invariant
1210+
// (sendShares broadcasts only txs in known_txs); m_remote_txs is used ONLY
1211+
// below to choose hash-vs-bytes encoding. Skipping the rest costs no
1212+
// propagation — a downloaded share is by definition already on the network we
1213+
// fetched it from, and our own minted shares are backable by construction
1214+
// (add_local_share declines a solve whose txs are not retained).
1215+
{
1216+
std::vector<ShareType> sendable;
1217+
sendable.reserve(shares.size());
1218+
size_t skipped_incomplete = 0;
1219+
for (auto& share : shares) {
1220+
bool backable = true;
1221+
share.invoke([&](auto* obj) {
1222+
backable = dash::all_txs_backable(obj->m_new_transaction_hashes,
1223+
m_known_txs);
1224+
});
1225+
if (backable)
1226+
sendable.push_back(std::move(share));
1227+
else
1228+
++skipped_incomplete;
1229+
}
1230+
if (skipped_incomplete > 0) {
1231+
static int skip_log = 0;
1232+
if (skip_log++ % 20 == 0)
1233+
LOG_INFO << "[send_shares] skipped " << skipped_incomplete
1234+
<< " share(s) whose new-tx bytes we do not hold "
1235+
"(downloaded via sharereply / template-evicted) to "
1236+
<< peer->addr().to_string()
1237+
<< " — avoids canonical 'unknown transaction' disconnect";
1238+
}
1239+
if (sendable.empty())
1240+
return {};
1241+
shares.swap(sendable);
1242+
}
11751243

11761244
// Forward the txs the peer needs BEFORE the shares (p2pool remember_tx
11771245
// protocol) — a share referencing unknown txs makes the receiving oracle
@@ -1225,6 +1293,14 @@ void NodeImpl::send_shares(peer_ptr peer, const std::vector<uint256>& share_hash
12251293

12261294
LOG_INFO << "[Pool] Sent " << shares.size() << " share(s) (+"
12271295
<< needed_txs.size() << " tx refs) to " << peer->addr().to_string();
1296+
1297+
// F2: report exactly which shares reached this peer so the caller marks only
1298+
// those broadcast (a share skipped for all peers stays un-marked → retried).
1299+
std::vector<uint256> sent;
1300+
sent.reserve(shares.size());
1301+
for (auto& share : shares)
1302+
sent.push_back(share.hash());
1303+
return sent;
12281304
}
12291305

12301306
uint256 NodeImpl::add_local_share(ShareType share)
@@ -1248,6 +1324,31 @@ uint256 NodeImpl::add_local_share(ShareType share)
12481324
return uint256::ZERO;
12491325
}
12501326

1327+
// F1-sub (by-construction backability): DECLINE a solve whose template
1328+
// txs are no longer retained in m_known_txs, rather than mint a share
1329+
// send_shares would then have to withhold. FrozenJobRegistry
1330+
// (mint_runloop.hpp) is a FIFO cap-512 with NO TTL and re-put refreshes,
1331+
// so a solve can arrive from an arbitrarily-old job — no finite retention
1332+
// window is a provable bound, so the only by-construction guarantee is to
1333+
// fail closed here: every share we add is fully forwardable. The miner
1334+
// keeps the pseudoshare credit; the next solve on a fresh job mints. The
1335+
// deduped rolling template window (register_template_txs) keeps declines
1336+
// rare — this fires only for genuinely stale solves past the window.
1337+
{
1338+
bool backable = true;
1339+
share.invoke([&](auto* obj) {
1340+
backable = dash::all_txs_backable(obj->m_new_transaction_hashes,
1341+
m_known_txs);
1342+
});
1343+
if (!backable) {
1344+
LOG_WARNING << "[MINT] local share " << hash.GetHex().substr(0, 16)
1345+
<< " references template tx(s) no longer retained — "
1346+
"declined (stale job past retention window; retry "
1347+
"on next solve)";
1348+
return uint256::ZERO;
1349+
}
1350+
}
1351+
12511352
m_tracker.add(share);
12521353
// Local share: verify inline (p2pool: set_best_share → think verifies;
12531354
// the inline attempt keeps the just-mint tip usable immediately).
@@ -1274,17 +1375,22 @@ void NodeImpl::register_template_txs(const std::vector<coin::Transaction>& txs,
12741375
<< " hashes=" << hashes.size() << " — skipped";
12751376
return;
12761377
}
1277-
// Drop the PREVIOUS template's leftovers that the new template no longer
1278-
// carries, then insert the new set — m_known_txs stays bounded by one
1279-
// template (plus reception-protocol remembered txs, which are peer-scoped).
1280-
std::set<uint256> new_set(hashes.begin(), hashes.end());
1281-
for (const auto& old_hash : m_template_tx_hashes) {
1282-
if (!new_set.count(old_hash))
1283-
m_known_txs.erase(old_hash);
1284-
}
1285-
for (size_t i = 0; i < txs.size(); ++i)
1286-
m_known_txs.insert_or_assign(hashes[i], txs[i]);
1287-
m_template_tx_hashes = std::move(new_set);
1378+
// Retain a rolling window of the last RETAINED_TEMPLATE_TX_SETS DISTINCT
1379+
// templates; a tx leaves m_known_txs only once it has fallen out of EVERY
1380+
// retained set — so a share minted on any recent job (the miner may solve a
1381+
// job whose template has since rotated) and a ROOT-2 re-announce a few
1382+
// rotations later can still forward the referenced tx bytes via remember_tx.
1383+
//
1384+
// code review F1: register_template_txs is called per producer-job cache
1385+
// MISS = per (prev_share_hash, payout_script) (main_dash.cpp:1539, key
1386+
// :1450), NOT per template rotation. A ~50-payout-script cluster re-registers
1387+
// the SAME template ~50 times on one tip, so pushing each unconditionally
1388+
// would collapse the window to a single template. retain_template_txs()
1389+
// dedups by TEMPLATE IDENTITY (the tx-hash set): a set already retained just
1390+
// refreshes recency and consumes no slot, so the window holds N DISTINCT
1391+
// templates (≈ minutes of stale-solve coverage), not N re-registrations.
1392+
dash::retain_template_txs(m_recent_template_tx_sets, m_known_txs,
1393+
hashes, txs, RETAINED_TEMPLATE_TX_SETS);
12881394
}
12891395

12901396
} // namespace dash

src/impl/dash/node.hpp

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
#include <atomic>
4343
#include <chrono>
4444
#include <functional>
45+
#include <deque>
4546
#include <map>
4647
#include <memory>
4748
#include <mutex>
@@ -242,10 +243,18 @@ class NodeImpl : public pool::BaseNode<dash::Config, dash::ShareChain, dash::Pee
242243

243244
// De-dup set for broadcast_share (hashes already relayed to peers).
244245
std::set<uint256> m_shared_share_hashes;
245-
// Hashes of the CURRENT template's txs registered in m_known_txs by
246-
// register_template_txs — replaced wholesale each registration so the
247-
// known-tx pool stays bounded by one template.
248-
std::set<uint256> m_template_tx_hashes;
246+
// Rolling history of recent templates' tx-hash sets (register_template_txs).
247+
// A locally minted share references its job's template txs; the miner may
248+
// solve a job whose template has since rotated (especially at high hashrate),
249+
// and ROOT-2 re-announce re-broadcasts a share after several rotations. If we
250+
// evict a tx the moment its template rotates (the old single-template bound),
251+
// send_shares can no longer forward its bytes via remember_tx, and the
252+
// canonical peer drops us with "referenced unknown transaction" (p2p.py:404).
253+
// Keep the last RETAINED_TEMPLATE_TX_SETS template tx-sets so a share minted
254+
// on any recent job stays fully backable; a tx leaves m_known_txs only once it
255+
// has fallen out of EVERY retained set (peer-remembered txs are separate).
256+
static constexpr size_t RETAINED_TEMPLATE_TX_SETS = 8;
257+
std::deque<std::set<uint256>> m_recent_template_tx_sets;
249258
// Fired on the IO thread when run_think() elects a new best share
250259
// (p2pool: new_work_event) — main_dash binds the stratum work refresh.
251260
std::function<void()> m_on_best_share_changed;
@@ -760,7 +769,10 @@ class NodeImpl : public pool::BaseNode<dash::Config, dash::ShareChain, dash::Pee
760769
/// the message_shares wire codec (+ remember_tx/forget_tx for txs the
761770
/// peer lacks). try_to_lock; deferred if the compute thread is busy.
762771
void broadcast_share(const uint256& share_hash);
763-
void send_shares(peer_ptr peer, const std::vector<uint256>& share_hashes);
772+
/// Returns the hashes actually SENT to `peer` (those passing the tx-
773+
/// completeness gate); broadcast_share marks only these as shared (F2).
774+
std::vector<uint256> send_shares(peer_ptr peer,
775+
const std::vector<uint256>& share_hashes);
764776

765777
/// Insert a locally-minted, ALREADY self-verified share into the tracker
766778
/// (+ LevelDB persist + attempt_verify), then broadcast + run_think.

test/CMakeLists.txt

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,8 @@ if (BUILD_TESTING AND GTest_FOUND)
402402
test_dash_share_producer_bind.cpp
403403
test_dash_mint_runloop.cpp
404404
test_dash_share_download.cpp
405-
test_dash_v36_share.cpp)
405+
test_dash_v36_share.cpp
406+
test_dash_known_txs_retention.cpp)
406407
target_link_libraries(test_dash_share_hash_link PRIVATE
407408
GTest::gtest_main GTest::gtest
408409
dash_x11 core
@@ -412,6 +413,17 @@ if (BUILD_TESTING AND GTest_FOUND)
412413
target_link_libraries(test_dash_share_hash_link PRIVATE c2pool_payout c2pool_hashrate c2pool_merged_mining) # OBJECT-lib SCC direct-naming (#22/#39): core stratum/web_server TUs
413414
gtest_add_tests(test_dash_share_hash_link "" AUTO)
414415

416+
# test_dash_known_txs_retention.cpp: pure retention/eviction semantics of the
417+
# known-tx pool that backs share broadcast (register_template_txs) — rolling
418+
# window, evict-only-after-a-tx-falls-out-of-ALL-retained-sets, the F1
419+
# template-identity dedup (N payout scripts on one template => ONE slot, not
420+
# N), recency refresh, and the F3/F2 broadcast gate. Header-only over
421+
# impl/dash/known_txs_retention.hpp + core (uint256). FOLDED into the
422+
# EXISTING allowlisted target test_dash_share_hash_link above (added to its
423+
# sources) — avoids the #769 new-target/allowlist trap (a standalone target
424+
# is not in build.yml's --target list, so CI never builds it and CTest
425+
# reports the tests "Not Run"). Its gtest cases run under that target.
426+
415427
# DASH block-subsidy + UTXO adapter (S7 foundation slice): GetBlockSubsidy /
416428
# masternode + platform reward split (subsidy.hpp) consumed through the
417429
# header-only utxo_adapter view shim. Both are header-only over

0 commit comments

Comments
 (0)