diff --git a/src/services/network_refresh_service.cpp b/src/services/network_refresh_service.cpp index 213b98f..6c58290 100644 --- a/src/services/network_refresh_service.cpp +++ b/src/services/network_refresh_service.cpp @@ -12,6 +12,7 @@ #include #include #include +#include using json = nlohmann::json; @@ -1172,11 +1173,33 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr // The first page holds last time's count plus headroom, so most wallets get every row in one // call (re-paging 1000 rows at a time formats ~rows^2/2000 rows); skip-paging stays the fallback. const int pageSize = transparentPageSize(snapshot.transparentRowHint); + std::unordered_set seenPagedRows; for (int skip = 0;; skip += pageSize) { json page = rpc.call("listtransactions", json::array({"*", pageSize, skip})); - appendTransparentTransactions(result.transactions, knownTxids, page, snapshot.miningAddresses); const std::size_t got = page.is_array() ? page.size() : 0; - result.transparentRowCount += got; + // C267: skip-paging walks a list that can shift between RPCs (a new tx pushes the + // previous page's tail into this page's head). De-dup on the daemon's stable per-output + // identity (txid, send/non-send class, vout). Key on the send/non-send CLASS, not the + // exact category, so a coinbase maturing (immature->generate) between the two fetches is + // still recognised as the same row; keep vout so two equal-value outputs of one tx stay + // distinct. Rows without txid or vout ("move") are never keyed and pass through + // (appendTransparentTransactions drops them anyway). + json uniq = json::array(); + if (page.is_array()) { + for (const auto& row : page) { + auto txid = readOptional(row, "txid"); + auto vout = readOptional(row, "vout"); + if (txid && !txid->empty() && vout) { + const bool isSend = + readOptional(row, "category").value_or("") == "send"; + std::string key = *txid + '|' + (isSend ? 's' : 'r') + '|' + std::to_string(*vout); + if (!seenPagedRows.insert(std::move(key)).second) continue; // true duplicate + } + uniq.push_back(row); + } + } + appendTransparentTransactions(result.transactions, knownTxids, uniq, snapshot.miningAddresses); + result.transparentRowCount += got; // RAW page size: drives the stop test + next row hint if (got < static_cast(pageSize)) break; } result.transparentChainOk = true; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index f8ea876..9f27179 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -2430,6 +2430,36 @@ void testNetworkRefreshRpcCollectors() EXPECT_EQ(pagedResult.transactions.size(), static_cast(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(2)); + EXPECT_EQ(dedupResult.transactions.size(), static_cast(1002)); + EXPECT_EQ(dedupResult.transparentRowCount, static_cast(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(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).