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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user