clusterlin: randomize equal-feerate parts of linearization (privacy)
What changed, and why it matters
This commit changes how Bitcoin Core orders transactions when grouping them into blocks or processing clusters. Previously, when several transaction groups had the same fee rate and no dependencies, the order was predictable based on internal data structures. Now the order is randomized. The stated goal is to hide information about the order in which transactions were inserted into the system, improving privacy by making it harder for observers to infer internal state from the final output.
Treat as a privacy-improvement maintenance patch. No urgent security action required. Reviewers may want to confirm that the random number generator used (m_rng) is cryptographically suitable for the privacy goal, and that PostLinearize's determinism properties are sufficient to avoid consensus-relevant non-determinism. Standard regression testing for transaction ordering and block template generation is appropriate.
Security signals we found
Privacy hardening: removes deterministic insertion-order leakage from linearization output
Randomization added to equal-feerate chunk selection and intra-chunk transaction ordering
PostLinearize retained to reduce non-determinism and preserve chunk connectivity invariants
No memory safety, input validation, or cryptographic changes observed
Evidence from the diff
The patch modifies the SpanningForestState linearization logic in src/cluster_linearize.h. It replaces deterministic tie-breaking (by chunk representative index) with random tie-breaking for equal-feerate chunks that have no dependencies between them, and also randomizes the order of transactions within chunks. A uint64_t random value from m_rng.rand64() is paired with each chunk in the ready_chunks heap, and transactions are popped from a ready queue after swapping a random element to the back. In src/txgraph.cpp, the comment around PostLinearize is updated to note that post-linearization now also serves to undo some of the non-determinism introduced by the randomization, while still guaranteeing connected chunks.
Changed components
src/cluster_linearize.h (SpanningForestState linearization heap and transaction queue)src/txgraph.cpp (GenericClusterImpl::Relinearize PostLinearize comment/behavior)Inspect captured patch +27 / −14
diff --git a/src/cluster_linearize.h b/src/cluster_linearize.h
index 6639244f..8b4d0d22 100644
--- a/src/cluster_linearize.h
+++ b/src/cluster_linearize.h
@@ -696,6 +696,12 @@ public:
* - Inside the selected chunk (see above), among the dependencies whose top feerate is strictly
* higher than its bottom feerate in the selected chunk, if any, a uniformly random dependency
* is deactivated.
+ *
+ * - How to decide the exact output linearization:
+ * - When there are multiple equal-feerate chunks with no dependencies between them, output a
+ * uniformly random one among the ones with no missing dependent chunks first.
+ * - Within chunks, repeatedly pick a uniformly random transaction among those with no missing
+ * dependencies.
*/
template<typename SetType>
class SpanningForestState
@@ -1178,8 +1184,8 @@ public:
std::vector<DepGraphIndex> ret;
ret.reserve(m_transaction_idxs.Count());
/** A heap with all chunks (by representative) that can currently be included, sorted by
- * chunk feerate. */
- std::vector<TxIdx> ready_chunks;
+ * chunk feerate and a random tie-breaker. */
+ std::vector<std::pair<TxIdx, uint64_t>> ready_chunks;
/** Information about chunks:
* - The first value is only used for chunk representatives, and counts the number of
* unmet dependencies this chunk has on other chunks (not including dependencies within
@@ -1205,25 +1211,27 @@ public:
}
// Construct a heap with all chunks that have no out-of-chunk dependencies.
/** Comparison function for the heap. */
- auto chunk_cmp_fn = [&](TxIdx a, TxIdx b) noexcept {
- auto& chunk_a = m_tx_data[a];
- auto& chunk_b = m_tx_data[b];
- Assume(chunk_a.chunk_rep == a);
- Assume(chunk_b.chunk_rep == b);
+ auto chunk_cmp_fn = [&](const std::pair<TxIdx, uint64_t>& a, const std::pair<TxIdx, uint64_t>& b) noexcept {
+ auto& chunk_a = m_tx_data[a.first];
+ auto& chunk_b = m_tx_data[b.first];
+ Assume(chunk_a.chunk_rep == a.first);
+ Assume(chunk_b.chunk_rep == b.first);
// First sort by chunk feerate.
if (chunk_a.chunk_setinfo.feerate != chunk_b.chunk_setinfo.feerate) {
return chunk_a.chunk_setinfo.feerate < chunk_b.chunk_setinfo.feerate;
}
- // Tie-break by chunk representative.
- return a < b;
+ // Tie-break randomly.
+ if (a.second != b.second) return a.second < b.second;
+ // Lastly, tie-break by chunk representative.
+ return a.first < b.first;
};
for (TxIdx chunk_rep : chunk_reps) {
- if (chunk_deps[chunk_rep].first == 0) ready_chunks.push_back(chunk_rep);
+ if (chunk_deps[chunk_rep].first == 0) ready_chunks.emplace_back(chunk_rep, m_rng.rand64());
}
std::make_heap(ready_chunks.begin(), ready_chunks.end(), chunk_cmp_fn);
// Pop chunks off the heap, highest-feerate ones first.
while (!ready_chunks.empty()) {
- auto chunk_rep = ready_chunks.front();
+ auto [chunk_rep, _rnd] = ready_chunks.front();
std::pop_heap(ready_chunks.begin(), ready_chunks.end(), chunk_cmp_fn);
ready_chunks.pop_back();
Assume(m_tx_data[chunk_rep].chunk_rep == chunk_rep);
@@ -1239,6 +1247,10 @@ public:
// Pick transactions from the ready queue, append them to linearization, and decrement
// dependency counts.
while (!ready_tx.empty()) {
+ // Move a random queue element to the back.
+ auto pos = m_rng.randrange(ready_tx.size());
+ if (pos != ready_tx.size() - 1) std::swap(ready_tx.back(), ready_tx[pos]);
+ // Pop from the back.
auto tx_idx = ready_tx.back();
Assume(chunk_txn[tx_idx]);
ready_tx.pop_back();
@@ -1259,7 +1271,7 @@ public:
Assume(chunk_deps[chl_data.chunk_rep].first > 0);
if (--chunk_deps[chl_data.chunk_rep].first == 0) {
// Child chunk has no dependencies left. Add it to the chunk heap.
- ready_chunks.push_back(chl_data.chunk_rep);
+ ready_chunks.emplace_back(chl_data.chunk_rep, m_rng.rand64());
std::push_heap(ready_chunks.begin(), ready_chunks.end(), chunk_cmp_fn);
}
}
diff --git a/src/txgraph.cpp b/src/txgraph.cpp
index 2004705f..3bc5cae6 100644
--- a/src/txgraph.cpp
+++ b/src/txgraph.cpp
@@ -2091,8 +2091,9 @@ std::pair<uint64_t, bool> GenericClusterImpl::Relinearize(TxGraphImpl& graph, in
// Invoke the actual linearization algorithm (passing in the existing one).
uint64_t rng_seed = graph.m_rng.rand64();
auto [linearization, optimal, cost] = Linearize(m_depgraph, max_iters, rng_seed, m_linearization);
- // Postlinearize to guarantee that the chunks of the resulting linearization are all connected.
- // (SFL currently does not guarantee connected chunks even when optimal).
+ // Postlinearize to undo some of the non-determinism caused by randomizing the linearization.
+ // This also guarantees that all chunks are connected (which is not guaranteed by SFL
+ // currently, even when optimal).
PostLinearize(m_depgraph, linearization);
// Update the linearization.
m_linearization = std::move(linearization);
Why this scored 28/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.