fix(tx): de-duplicate transparent rows across listtransactions pages
Transparent history is gathered by skip-paging listtransactions. The
underlying list can shift between the per-page RPCs - a new
transaction arriving pushes one page's tail into the next page's head
- so a row could be collected twice and show up as a duplicate in
History.
- Pages are now de-duplicated on the daemon's stable per-output
identity: txid, send/non-send class, and vout. Keying on the class
rather than the exact category means a coinbase that matures
(immature to generate) between two fetches is still recognised as
the same row; keeping vout means two equal-value outputs of one
transaction stay distinct. No real transaction row is dropped or
duplicated.
- Rows with no txid or vout ("move") are never keyed and pass through
(they are dropped downstream anyway), so the de-dup fails open.
- The raw page size still drives the stop condition and the next-run
row hint, independent of how many rows were unique.
Tests cover a shifted page whose head repeats the prior page's last
row (dropped), a sibling output of the same tx (kept), and an
un-keyable row (kept).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2430,6 +2430,36 @@ void testNetworkRefreshRpcCollectors()
|
||||
EXPECT_EQ(pagedResult.transactions.size(), static_cast<size_t>(1001));
|
||||
EXPECT_EQ(pagedResult.transactions[0].txid, std::string("paged-1000"));
|
||||
|
||||
{
|
||||
// C267: a tx added between page RPCs shifts the list, so the previous page's last row reappears at
|
||||
// the head of the next page. Rows (txid, send/non-send, vout) already taken are dropped; a sibling
|
||||
// output of the same tx (different vout) and an un-keyable row (no vout) are kept (fail-open).
|
||||
Refresh::TransactionRefreshSnapshot dedupSnapshot;
|
||||
MockRefreshRpc dedupRpc;
|
||||
json page1 = json::array();
|
||||
for (int i = 0; i < 1000; ++i) {
|
||||
page1.push_back(json{{"txid", "dd-" + std::to_string(i)}, {"vout", 0}, {"category", "receive"},
|
||||
{"amount", 0.01}, {"time", i}, {"confirmations", 10},
|
||||
{"address", "R-dd"}});
|
||||
}
|
||||
dedupRpc.addResponse("listtransactions", page1);
|
||||
dedupRpc.addResponse("listtransactions", json::array({
|
||||
json{{"txid", "dd-999"}, {"vout", 0}, {"category", "receive"}, {"amount", 0.01},
|
||||
{"time", 999}, {"confirmations", 10}, {"address", "R-dd"}}, // repeat: dropped
|
||||
json{{"txid", "dd-999"}, {"vout", 1}, {"category", "receive"}, {"amount", 0.01},
|
||||
{"time", 999}, {"confirmations", 10}, {"address", "R-dd"}}, // sibling vout: kept
|
||||
json{{"txid", "dd-novout"}, {"category", "receive"}, {"amount", 0.02},
|
||||
{"time", 1001}, {"confirmations", 9}, {"address", "R-dd"}} // no vout: kept
|
||||
}));
|
||||
auto dedupResult = Refresh::collectTransactionRefreshResult(dedupRpc, dedupSnapshot, 328, 0);
|
||||
EXPECT_EQ(dedupRpc.calls.size(), static_cast<size_t>(2));
|
||||
EXPECT_EQ(dedupResult.transactions.size(), static_cast<size_t>(1002));
|
||||
EXPECT_EQ(dedupResult.transparentRowCount, static_cast<size_t>(1003)); // RAW count, not deduped
|
||||
size_t dd999 = 0;
|
||||
for (const auto& t : dedupResult.transactions) if (t.txid == "dd-999") ++dd999;
|
||||
EXPECT_EQ(dd999, static_cast<size_t>(2)); // vout 0 once + vout 1
|
||||
}
|
||||
|
||||
// The pass floor: an address scanned below it is scanned again; without a floor (no pass running)
|
||||
// only never-scanned addresses are. A pass's floor is its start height, so it completes however far
|
||||
// the tip moves meanwhile (this is what unsticks the perpetual "refreshing history" state).
|
||||
|
||||
Reference in New Issue
Block a user