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:
2026-10-14 07:21:07 -05:00
co-authored by Claude Opus 4.8
parent 6a6ac8cf99
commit 25ad23aee4
2 changed files with 55 additions and 2 deletions
+30
View File
@@ -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).