refactor(history): make TransactionHistoryCache safe for a writer thread

The tx-history cache was only ever used from the UI thread and had no
locking. To let snapshots be written from a background thread:

- every SQL statement runs under one database mutex and the key fields
  under a second one, taken after it and never held across I/O or the
  key derivation;
- a write is a self-contained, move-only SnapshotJob holding the rows
  and a private copy of the key, which is wiped after the write or when
  a job never runs; the JSON build and encryption hold no lock;
- a job with an all-zero key is refused instead of overwriting a good
  snapshot with one nobody can read;
- the prune after a write also keeps the wallet unlocked now, so a late
  job for the previous wallet can't delete the salt of the one just
  switched to;
- keyId() gives a non-reversible tag of the installed key, so a
  passphrase change can be told from a re-unlock with the same one.

replace() stays a synchronous wrapper with the same result. A failed
INSERT is now logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 21:09:48 -05:00
co-authored by Claude Opus 5.5
parent 67260cc6f0
commit c0568eb930
3 changed files with 258 additions and 37 deletions
+73
View File
@@ -6102,6 +6102,78 @@ void testTransactionHistoryCachePrunesOldWallets()
removeTree(dir);
}
// The background writer's path: a job carries its own key copy (a lockKey() after queueing cannot drop it),
// the copy is wiped, a keyless job is refused, keyId() tracks the key across lock/unlock, and a late job for the
// previous wallet keeps the salt of the wallet unlocked since.
void testTransactionHistoryCacheBackgroundWrite()
{
using dragonx::TransactionInfo;
using dragonx::data::TransactionHistoryCache;
fs::path dir = makeTempDir();
const std::string dbPath = (dir / "transaction_history.sqlite").string();
const std::string idA = "mainnet|R-a|zs-a";
const std::string idB = "mainnet|R-a|zs-a|zs-b";
const std::string pass = "correct horse battery staple";
const std::vector<TransactionInfo> txs{TransactionInfo{"tx1", "mined", 1.0, 1700000000, 10, "R-a", "", ""}};
TransactionHistoryCache cache(dbPath);
EXPECT_EQ(cache.keyId(), static_cast<std::uint64_t>(0));
EXPECT_TRUE(cache.unlockWithPassphrase(idA, pass));
const std::uint64_t idOfPass = cache.keyId();
EXPECT_TRUE(idOfPass != 0);
std::array<unsigned char, 32> scratch{};
EXPECT_FALSE(cache.captureKey(idB, scratch)); // not unlocked for B
{ // a keyless job is refused: it would replace the snapshot with one nobody can read
TransactionHistoryCache::SnapshotJob keyless;
keyless.walletIdentity = idA;
keyless.transactions = txs;
EXPECT_FALSE(cache.writeSnapshot(std::move(keyless)));
EXPECT_EQ(cache.snapshotCount(), 0);
}
// A job captured before lockKey() still lands after it (flush-before-lock).
TransactionHistoryCache::SnapshotJob job;
job.walletIdentity = idA;
EXPECT_TRUE(cache.captureKey(idA, job.key));
job.tipHeight = 100;
job.tipHash = "tip";
job.transactions = txs;
job.updatedAt = 1700000000;
cache.lockKey();
EXPECT_FALSE(cache.isUnlockedFor(idA));
EXPECT_TRUE(cache.writeSnapshot(std::move(job)));
EXPECT_TRUE(job.key == (std::array<unsigned char, 32>{})); // wiped
EXPECT_EQ(cache.keyId(), idOfPass); // survives lockKey()
EXPECT_TRUE(cache.unlockWithPassphrase(idA, pass));
EXPECT_EQ(cache.keyId(), idOfPass); // same passphrase -> same id
EXPECT_TRUE(cache.load(idA, 100, "tip").loaded);
EXPECT_TRUE(cache.unlockWithPassphrase(idA, "another passphrase"));
EXPECT_TRUE(cache.keyId() != idOfPass); // a passphrase change is visible
// A late job for wallet A must not prune the salt of B, unlocked since.
EXPECT_TRUE(cache.unlockWithPassphrase(idA, pass));
TransactionHistoryCache::SnapshotJob lateA;
lateA.walletIdentity = idA;
EXPECT_TRUE(cache.captureKey(idA, lateA.key));
lateA.tipHeight = 100;
lateA.tipHash = "tip";
lateA.transactions = txs;
lateA.updatedAt = 1700000001;
EXPECT_TRUE(cache.unlockWithPassphrase(idB, pass));
EXPECT_TRUE(cache.writeSnapshot(std::move(lateA)));
EXPECT_EQ(cache.snapshotCount(), 1); // A's row; B's salt row must survive
EXPECT_TRUE(cache.replace(idB, 100, "tip-b", txs, 1700000002));
EXPECT_EQ(cache.snapshotCount(), 1); // B's own write prunes A
{
TransactionHistoryCache reopened(dbPath);
EXPECT_TRUE(reopened.unlockWithPassphrase(idB, pass));
EXPECT_TRUE(reopened.load(idB, 100, "tip-b").loaded); // the salt B's key came from survived
}
removeTree(dir);
}
void testTransactionHistoryCacheRefreshApply()
{
using Refresh = dragonx::services::NetworkRefreshService;
@@ -12728,6 +12800,7 @@ int main()
testExplorerBlockCache();
testTransactionHistoryCache();
testTransactionHistoryCachePrunesOldWallets();
testTransactionHistoryCacheBackgroundWrite();
testAddressBookScope();
testWalletIndex();
testTransactionHistoryCacheRefreshApply();