z_listsentbyaddress re-walks the whole wallet when there's no usable watermark, which the daemon does under cs_main for every send -- hundreds of seconds, 400+ s and ~19 GB RSS on a large wallet. The watermark lived only in memory, so every relaunch, reconnect or daemon restart paid for a full sweep. This persists it so those cases run a bounded windowed sweep ([z, 0, 2, tip - W + margin]) instead (SENT-HISTORY-SWEEP Part 1). - The watermark (W = last swept tip), its anchor (A = W - margin) and anchor hash (H_A = getblockhash(A)), plus the enrichment backlog and the full- sweep backoff, ride in the encrypted history snapshot as an optional `sent_sweep` object (schema stays v1; a missing/inconsistent one reads as "no watermark" = today's behaviour, so older builds and payloads are safe). - Before the first windowed sweep after a load, the anchor is verified (getblockhash(A) == H_A). A match means any fork is inside the window; a mismatch (reorg deeper than the margin, different chain/datadir) or an inability to check drops the watermark and sweeps the whole wallet. - The first sweep after a load uses a bigger margin (absorbs notifier lag), and a watermark older than 2400 blocks uses the long timeout (callLong). - The backlog is persisted WITH W: a pure z->z send's txid lives only in a sweep result and would be lost if the window moved past an un-enriched one. - Watermark changes reach disk (folded into the store signature), and every hard case still forces a full sweep: reorg (anchor verify), GUI rescan / zap / import / address change, restoreOriginalWallet, and disconnect. Deferred (follow-ups, each only turns a correct-but-slow case faster, never a correctness risk): the banded full sweep for seeding on a pre-note-cache daemon, the external-import tripwire, and the weekly safety-net re-sweep. Part 2 (the daemon's decrypt cost) is already on the daemon's perf/sapling-note-cache branch. Pure helpers + cache round-trip + anchor-verify/large-window covered by tests; the getblockhash-after-sweep updates the pinned RPC sequences. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
146 lines
5.7 KiB
C++
146 lines
5.7 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
|
|
#include "transaction_history_store_policy.h"
|
|
|
|
#include <cstring>
|
|
|
|
namespace dragonx {
|
|
namespace data {
|
|
|
|
namespace {
|
|
|
|
constexpr std::uint64_t kOffset = 0xcbf29ce484222325ULL;
|
|
constexpr std::uint64_t kMul = 0x9E3779B97F4A7C15ULL; // odd
|
|
|
|
static_assert(sizeof(double) == sizeof(std::uint64_t), "amount bit pattern is hashed as 64 bits");
|
|
|
|
// 8 bytes per step: a 5k-row wallet (~1.5 MB of row text) hashes in about a millisecond. Endianness-dependent
|
|
// on purpose: the value never leaves the process.
|
|
inline void mixBytes(std::uint64_t& h, const void* data, std::size_t n)
|
|
{
|
|
const auto* b = static_cast<const unsigned char*>(data);
|
|
while (n >= 8) {
|
|
std::uint64_t w;
|
|
std::memcpy(&w, b, 8);
|
|
h = (h ^ w) * kMul;
|
|
h ^= h >> 32;
|
|
b += 8;
|
|
n -= 8;
|
|
}
|
|
if (n) {
|
|
std::uint64_t w = 0;
|
|
std::memcpy(&w, b, n);
|
|
h = (h ^ w ^ (static_cast<std::uint64_t>(n) << 56)) * kMul;
|
|
h ^= h >> 32;
|
|
}
|
|
}
|
|
inline void mixU64(std::uint64_t& h, std::uint64_t v) { mixBytes(h, &v, sizeof(v)); }
|
|
inline void mixStr(std::uint64_t& h, const std::string& s)
|
|
{
|
|
mixU64(h, s.size()); // length first: ("ab","c") != ("a","bc")
|
|
mixBytes(h, s.data(), s.size());
|
|
}
|
|
inline std::uint64_t finalize(std::uint64_t x) // splitmix64 finalizer
|
|
{
|
|
x += 0x9e3779b97f4a7c15ULL;
|
|
x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9ULL;
|
|
x = (x ^ (x >> 27)) * 0x94d049bb133111ebULL;
|
|
return x ^ (x >> 31);
|
|
}
|
|
// The thresholds a stored row is read at: transactions_tab.cpp:788-793 / transaction_details_dialog.cpp:80-85
|
|
// (0 pending, < 10 counted, >= 100 mature for mined), mining_earnings.cpp:102 (>= 100), the confirmed-tx cache
|
|
// (>= 10, app_network.cpp:1915) and conflicted (< 0, network_refresh_service.cpp:1470).
|
|
inline std::uint64_t confirmationBucket(int c)
|
|
{
|
|
return c < 0 ? 0 : c == 0 ? 1 : c < 10 ? 2 : c < 100 ? 3 : 4;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
HistorySnapshotSig historySnapshotSignature(const std::vector<TransactionInfo>& transactions,
|
|
const std::unordered_set<std::string>& skipTxids,
|
|
const std::string& walletHash,
|
|
std::uint64_t keyId,
|
|
const std::unordered_map<std::string, int>& shieldedScanHeights,
|
|
int tipHeight,
|
|
const std::string& tipHash,
|
|
int sentHeight,
|
|
int sentAnchorHeight,
|
|
const std::string& sentAnchorHash,
|
|
const std::unordered_set<std::string>& sentBacklog)
|
|
{
|
|
HistorySnapshotSig sig;
|
|
std::uint64_t rowSum = 0; // sums: commutative, so row order does not matter; duplicates still count
|
|
std::uint64_t exactSum = 0;
|
|
for (const auto& tx : transactions) {
|
|
if (!skipTxids.empty() && skipTxids.count(tx.txid)) continue;
|
|
std::uint64_t h = kOffset;
|
|
mixStr(h, tx.txid);
|
|
mixStr(h, tx.type);
|
|
std::uint64_t amountBits = 0;
|
|
std::memcpy(&amountBits, &tx.amount, sizeof(amountBits));
|
|
mixU64(h, amountBits);
|
|
mixU64(h, static_cast<std::uint64_t>(tx.timestamp));
|
|
mixStr(h, tx.address);
|
|
mixStr(h, tx.from_address);
|
|
mixStr(h, tx.memo);
|
|
std::uint64_t e = h;
|
|
mixU64(h, confirmationBucket(tx.confirmations));
|
|
mixU64(e, static_cast<std::uint64_t>(static_cast<std::int64_t>(tx.confirmations)));
|
|
rowSum += finalize(h);
|
|
exactSum += finalize(e);
|
|
++sig.rows;
|
|
}
|
|
std::uint64_t backlogSum = 0; // order-independent: a set, so sum is commutative
|
|
for (const auto& txid : sentBacklog) {
|
|
if (txid.empty()) continue;
|
|
std::uint64_t h = kOffset;
|
|
mixStr(h, txid);
|
|
backlogSum += finalize(h);
|
|
}
|
|
std::uint64_t c = kOffset;
|
|
mixU64(c, rowSum);
|
|
mixU64(c, sig.rows);
|
|
mixStr(c, walletHash);
|
|
mixU64(c, keyId);
|
|
mixU64(c, backlogSum);
|
|
sig.content = finalize(c);
|
|
|
|
std::uint64_t heightSum = 0;
|
|
for (const auto& [address, height] : shieldedScanHeights) {
|
|
if (address.empty() || height < 0) continue; // exactly what replace() persists
|
|
std::uint64_t h = kOffset;
|
|
mixStr(h, address);
|
|
mixU64(h, static_cast<std::uint64_t>(height));
|
|
heightSum += finalize(h);
|
|
}
|
|
std::uint64_t s = kOffset;
|
|
mixU64(s, heightSum);
|
|
mixU64(s, exactSum);
|
|
mixU64(s, static_cast<std::uint64_t>(static_cast<std::int64_t>(tipHeight)));
|
|
mixStr(s, tipHash);
|
|
mixU64(s, static_cast<std::uint64_t>(static_cast<std::int64_t>(sentHeight)));
|
|
mixU64(s, static_cast<std::uint64_t>(static_cast<std::int64_t>(sentAnchorHeight)));
|
|
mixStr(s, sentAnchorHash);
|
|
sig.slow = finalize(s);
|
|
return sig;
|
|
}
|
|
|
|
HistoryStoreAction decideHistoryStore(const HistoryStoreInput& in)
|
|
{
|
|
const bool contentDirty = !in.haveStored || in.stored.content != in.current.content;
|
|
const bool slowDirty = in.haveStored && in.stored.slow != in.current.slow;
|
|
if (in.mode == HistoryStoreMode::Now)
|
|
return (contentDirty || slowDirty) ? HistoryStoreAction::Store : HistoryStoreAction::Skip;
|
|
if (contentDirty)
|
|
return in.sinceLastSubmitSec >= kHistoryMinStoreIntervalSec ? HistoryStoreAction::Store
|
|
: HistoryStoreAction::Defer;
|
|
if (slowDirty && in.sinceLastSubmitSec >= kHistorySlowStoreIntervalSec) return HistoryStoreAction::Store;
|
|
return HistoryStoreAction::Skip;
|
|
}
|
|
|
|
} // namespace data
|
|
} // namespace dragonx
|