Skip to content

Commit 45e1cae

Browse files
authored
Merge pull request #623 from frstrtr/dash/g1-txselect-determinism
dash(G1): deterministic equal-feerate tx selection (byte-parity seam)
2 parents a3cdd5b + 23dd87e commit 45e1cae

2 files changed

Lines changed: 180 additions & 12 deletions

File tree

src/impl/dash/coin/mempool.hpp

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
#include <cstdint>
5050
#include <ctime>
5151
#include <map>
52+
#include <set>
5253
#include <mutex>
5354
#include <optional>
5455
#include <utility>
@@ -72,6 +73,32 @@ struct MempoolEntry {
7273
}
7374
};
7475

76+
/// Deterministic block-template selection key. Sorts highest-feerate
77+
/// first; ties broken by txid ascending. Byte-reproducible across
78+
/// nodes/runs AND bit-for-bit conformant with dashcore BlockAssembler
79+
/// ordering. dashcore CompareTxMemPoolEntryByAncestorFee compares two
80+
/// entries by cross-multiplication of (fee, size) as doubles --
81+
/// f1 = a.fee * b.size vs f2 = b.fee * a.size -- explicitly "avoid
82+
/// division by rewriting (a/b > c/d) as (a*d > c*b)". A pre-divided
83+
/// fee/base_size double rounds where the cross-multiply does not, so the
84+
/// two representations can order a tie-pair differently. We therefore
85+
/// carry (fee, base_size) and reproduce the exact double cross-multiply;
86+
/// equal feerate is broken by GetHash()/txid ascending, as the oracle
87+
/// does. (No ancestor packages in this simplified mempool, so
88+
/// GetModFeeAndSize reduces to the entry's own (fee, base_size).)
89+
struct FeeKey {
90+
uint64_t fee; // satoshi
91+
uint32_t base_size; // serialized bytes (>0 for every indexed entry)
92+
uint256 txid;
93+
bool operator<(const FeeKey& o) const {
94+
// dashcore CompareTxMemPoolEntryByAncestorFee, division-free form.
95+
const double f1 = static_cast<double>(fee) * o.base_size;
96+
const double f2 = static_cast<double>(o.fee) * base_size;
97+
if (f1 != f2) return f1 > f2; // higher feerate first
98+
return txid < o.txid; // txid asc tiebreak (oracle-conformant)
99+
}
100+
};
101+
75102
class Mempool {
76103
public:
77104
static constexpr size_t DEFAULT_MAX_BYTES = 300ULL * 1024 * 1024;
@@ -139,7 +166,7 @@ class Mempool {
139166
// recompute_unknown_fees() resolves them after a UTXO update.
140167
// Uses negative feerate as the multimap key so begin() = best.
141168
if (stored.fee_known) {
142-
m_feerate_index.emplace(stored.feerate_satvb(), txid);
169+
m_feerate_index.insert(FeeKey{stored.fee, stored.base_size, txid});
143170
}
144171

145172
// Periodic stats — every 100 entries + first 5 + every eviction.
@@ -243,7 +270,7 @@ class Mempool {
243270
for (auto& [txid, entry] : m_pool) {
244271
if (entry.fee_known) continue;
245272
if (compute_fee_locked(entry, utxo)) {
246-
m_feerate_index.emplace(entry.feerate_satvb(), txid);
273+
m_feerate_index.insert(FeeKey{entry.fee, entry.base_size, txid});
247274
resolved_fees += entry.fee;
248275
++resolved;
249276
} else {
@@ -346,8 +373,8 @@ class Mempool {
346373
uint32_t total_bytes = 0;
347374
auto* utxo = m_utxo.load();
348375

349-
for (const auto& [feerate, txid] : m_feerate_index) {
350-
(void)feerate;
376+
for (const auto& fk : m_feerate_index) {
377+
const uint256& txid = fk.txid;
351378
auto pit = m_pool.find(txid);
352379
if (pit == m_pool.end()) continue;
353380
const auto& entry = pit->second;
@@ -388,7 +415,7 @@ class Mempool {
388415
std::map<std::pair<uint256, uint32_t>, uint256> m_spent_outputs;
389416
// Step 2: feerate-sorted index. greater<double> means begin() =
390417
// highest feerate, so iteration is best-first.
391-
std::multimap<double, uint256, std::greater<double>> m_feerate_index;
418+
std::set<FeeKey> m_feerate_index;
392419
size_t m_total_bytes{0};
393420
size_t m_max_bytes;
394421
time_t m_expiry_sec;
@@ -415,13 +442,7 @@ class Mempool {
415442

416443
// Drop from feerate index (only present if fee was known).
417444
if (it->second.fee_known) {
418-
auto frng = m_feerate_index.equal_range(it->second.feerate_satvb());
419-
for (auto rit = frng.first; rit != frng.second; ++rit) {
420-
if (rit->second == txid) {
421-
m_feerate_index.erase(rit);
422-
break;
423-
}
424-
}
445+
m_feerate_index.erase(FeeKey{it->second.fee, it->second.base_size, txid});
425446
}
426447

427448
// Drop from spent-outputs index.

test/test_dash_mempool.cpp

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,16 @@
2525
#include <core/uint256.hpp>
2626
#include <core/coin/utxo_view_cache.hpp>
2727

28+
#include <algorithm>
2829
#include <cstdint>
30+
#include <set>
31+
#include <vector>
2932

3033
using dash::coin::Mempool;
3134
using dash::coin::MutableTransaction;
3235
using dash::coin::dash_txid;
3336
using dash::coin::BlockType;
37+
using dash::coin::FeeKey;
3438
using ::core::coin::UTXOViewCache;
3539
using ::core::coin::Outpoint;
3640
using ::core::coin::Coin;
@@ -254,3 +258,146 @@ TEST(DashMempool, SortedSelectionHighestFeerateFirst)
254258
EXPECT_EQ(dash_txid(selected[1].tx), dash_txid(tx_lo));
255259
EXPECT_EQ(total_fees, 11'000u);
256260
}
261+
262+
// ─── G1 byte-parity: equal-feerate selection is deterministic ────────────────
263+
//
264+
// The embedded GBT template builder serializes txs in the order
265+
// get_sorted_txs_with_fees() returns them. For txs sharing the SAME
266+
// feerate the old std::multimap<double,uint256> kept them in mempool
267+
// INSERTION order, so two nodes with the same mempool contents but a
268+
// different arrival order produced different template bytes — a
269+
// non-deterministic seam that breaks G1 byte-parity against the
270+
// p2pool-dash / dashcore oracle. FeeKey now breaks feerate ties by txid
271+
// ascending (matches dashcore CompareTxMemPoolEntryByAncestorFee's
272+
// GetHash() tiebreak), so the projection is byte-reproducible.
273+
//
274+
// This KAT pins that: identical equal-feerate sets added in OPPOSITE
275+
// orders must yield the SAME selection, ordered by txid ascending.
276+
static std::vector<uint256> equal_feerate_selection(bool reverse_insertion)
277+
{
278+
UTXOViewCache utxo(nullptr);
279+
// 5 distinct prevouts, identical value ⇒ identical fee & base_size
280+
// ⇒ identical feerate for every spend below.
281+
constexpr int N = 5;
282+
std::vector<MutableTransaction> txs;
283+
for (int i = 0; i < N; ++i) {
284+
uint256 prev = mint_hash(200 + i);
285+
utxo.add_coin(Outpoint(prev, 0), Coin(100'000, {}, 1, false));
286+
txs.push_back(make_spend(prev, 0, /*out=*/95'000, /*salt=*/300 + i)); // fee 5000
287+
}
288+
289+
Mempool mp;
290+
mp.set_utxo(&utxo);
291+
if (reverse_insertion)
292+
for (auto it = txs.rbegin(); it != txs.rend(); ++it) EXPECT_TRUE(mp.add_tx(*it));
293+
else
294+
for (auto& t : txs) EXPECT_TRUE(mp.add_tx(t));
295+
296+
auto [selected, total_fees] = mp.get_sorted_txs_with_fees(/*max_bytes=*/1u << 20);
297+
EXPECT_EQ(total_fees, static_cast<uint64_t>(N) * 5'000u);
298+
std::vector<uint256> out;
299+
for (auto& s : selected) out.push_back(dash_txid(s.tx));
300+
return out;
301+
}
302+
303+
TEST(DashMempool, EqualFeerateSelectionIsTxidAscendingAndInsertionOrderIndependent)
304+
{
305+
auto forward = equal_feerate_selection(/*reverse_insertion=*/false);
306+
auto reverse = equal_feerate_selection(/*reverse_insertion=*/true);
307+
308+
ASSERT_EQ(forward.size(), 5u);
309+
ASSERT_EQ(reverse.size(), 5u);
310+
311+
// Insertion order must not affect the projected order.
312+
EXPECT_EQ(forward, reverse)
313+
<< "equal-feerate tx order must be independent of mempool arrival order";
314+
315+
// And that stable order is txid ascending (dashcore GetHash() tiebreak).
316+
auto sorted = forward;
317+
std::sort(sorted.begin(), sorted.end());
318+
EXPECT_EQ(forward, sorted)
319+
<< "equal-feerate ties must resolve to txid-ascending, oracle-conformant order";
320+
}
321+
322+
323+
// --- G1 byte-parity: feerate compare is dashcore division-free cross-multiply
324+
//
325+
// dashcore CompareTxMemPoolEntryByAncestorFee compares two entries by
326+
// cross-multiplication -- f1 = a.fee * b.size vs f2 = b.fee * a.size --
327+
// explicitly to "avoid division by rewriting (a/b > c/d) as (a*d > c*b)".
328+
// c2pool previously keyed the sorted index on a PRE-DIVIDED double
329+
// (fee / base_size). That division rounds, so it can collapse a strict
330+
// dashcore order into a tie -- or split a dashcore tie into a strict
331+
// order -- making the two representations disagree on the selection
332+
// order of certain (fee, size) pairs. A different selection order is a
333+
// different template byte-serialization: a latent G1 byte-parity seam
334+
// against the p2pool-dash / dashcore oracle. FeeKey now carries
335+
// (fee, base_size) and reproduces the exact double cross-multiply.
336+
//
337+
// Divergence vector (found by exhaustive search). The disagreement only
338+
// manifests at fee magnitudes >~1e14 sat, where the fee/size division
339+
// loses ULPs the cross-multiply keeps -- for realistic magnitudes the
340+
// two representations agree, so this fix is exact-oracle-conformance
341+
// hardening, not a realistic-value bug:
342+
// A = (fee 182912374030878, size 3535)
343+
// B = (fee 4415613369921651, size 85337)
344+
// Pre-divided doubles: A -> 51743245836.174819946 < B -> 51743245836.174827576,
345+
// i.e. the OLD code ranks B strictly ABOVE A.
346+
// Cross-multiply: A.fee*B.size == B.fee*A.size exactly -> a genuine
347+
// feerate TIE, resolved by txid ascending (dashcore GetHash()).
348+
TEST(DashMempool, FeerateCompareIsDivisionFreeCrossMultiplyNotPreDividedDouble)
349+
{
350+
// Independent dashcore-style reference (division-free cross-multiply).
351+
auto oracle_less = [](uint64_t fa, uint32_t sa, const uint256& ta,
352+
uint64_t fb, uint32_t sb, const uint256& tb) {
353+
const double f1 = static_cast<double>(fa) * sb;
354+
const double f2 = static_cast<double>(fb) * sa;
355+
if (f1 != f2) return f1 > f2; // higher feerate first
356+
return ta < tb; // txid ascending
357+
};
358+
359+
// Two distinct txids; assign the SMALLER to A so a correct
360+
// (cross-multiply => tie => txid-asc) key orders A before B, whereas
361+
// the old pre-divide code would have put B first ("B higher feerate").
362+
const uint256 t0 = mint_hash(9001);
363+
const uint256 t1 = mint_hash(9002);
364+
const uint256 ta = std::min(t0, t1);
365+
const uint256 tb = std::max(t0, t1);
366+
ASSERT_TRUE(ta < tb);
367+
368+
const uint64_t fa = 182912374030878ULL;
369+
const uint32_t sa = 3535u;
370+
const uint64_t fb = 4415613369921651ULL;
371+
const uint32_t sb = 85337u;
372+
373+
// Precondition on the vector: a genuine cross-multiply tie that the
374+
// buggy pre-divide would (wrongly) have seen as a strict order.
375+
EXPECT_EQ(static_cast<double>(fa) * sb, static_cast<double>(fb) * sa)
376+
<< "vector must be a genuine cross-multiply feerate tie";
377+
EXPECT_LT(static_cast<double>(fa) / sa, static_cast<double>(fb) / sb)
378+
<< "vector must be a strict (wrong) order under the old pre-divide";
379+
380+
const FeeKey A{fa, sa, ta};
381+
const FeeKey B{fb, sb, tb};
382+
383+
// FeeKey resolves the cross-multiply tie by txid ascending => A first.
384+
EXPECT_TRUE(A < B) << "cross-multiply tie must fall to txid-ascending (A<B)";
385+
EXPECT_FALSE(B < A);
386+
387+
// FeeKey ordering must agree with the independent oracle on this pair.
388+
EXPECT_EQ(A < B, oracle_less(fa, sa, ta, fb, sb, tb));
389+
EXPECT_EQ(B < A, oracle_less(fb, sb, tb, fa, sa, ta));
390+
391+
// The real index type (std::set<FeeKey>) must iterate A best-first.
392+
std::set<FeeKey> idx{B, A};
393+
ASSERT_EQ(idx.size(), 2u);
394+
EXPECT_EQ(idx.begin()->txid, ta)
395+
<< "best-first iteration must yield A (smaller txid) on the tie";
396+
397+
// Sanity: the feerate arm still dominates. Strictly higher feerate
398+
// must precede regardless of a smaller-txid competitor.
399+
const FeeKey hi{20000u, 250u, tb}; // 80 sat/byte
400+
const FeeKey lo{10000u, 250u, ta}; // 40 sat/byte, but smaller txid
401+
EXPECT_TRUE(hi < lo) << "higher feerate must precede regardless of txid";
402+
EXPECT_FALSE(lo < hi);
403+
}

0 commit comments

Comments
 (0)