wallet/init: decrypt every wallet note into the plaintext cache at startup
The plaintext cache is in memory only, so a restart empties it. Without a warm-up, the first balance call after a restart decrypts the whole wallet under LOCK2(cs_main, cs_wallet): ~6s for a 12k-note wallet. That is the observed failure: restart, the GUI connects, and the node looks stuck. CWallet::WarmSaplingNoteCache decrypts every note whose plaintext isn't cached, spent ones included, because z_listreceivedbyaddress (ignoreSpent=false) is called once per address by the GUI's history scan. init runs it at the end of the wallet block, just before SetBroadcastTransactions. At that point RPC is still in warmup (it shows "Decrypting wallet notes...") and the wallet notification thread, whose startup witness rebuild can hold cs_wallet for a long time, has not started yet. It takes cs_wallet only. -notecachewarmup=0 (debug help) skips it, which leaves the lazy fill in GetFilteredNotes to do the work. Unlike GetFilteredNotes, the warm-up never asserts. A note that doesn't decrypt under its ivk, or names an output its tx lacks, is counted in the summary line and listed under -debug=zrpcunsafe. A corrupt note therefore can't stop the node from starting, or from serving the key-export RPCs allowed during warmup; GetFilteredNotes still asserts on it, as before. Tests: - WarmUpFillsEveryNote: spent, locked, unconfirmed and stale-block notes included; walks afterwards decrypt nothing. - WarmUpSkipsBadNotes: undecryptable and out-of-range notes are skipped without aborting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -467,7 +467,7 @@ TEST_F(WalletNoteCache, HeightFromDepth) {
|
|||||||
|
|
||||||
// Two notes recorded under A's viewing key that it can't actually decrypt (real outputs to someone
|
// Two notes recorded under A's viewing key that it can't actually decrypt (real outputs to someone
|
||||||
// else): output 0 is spent by a mined tx, output 1 is locked. If the walk decrypted either one, the
|
// else): output 0 is spent by a mined tx, output 1 is locked. If the walk decrypted either one, the
|
||||||
// decrypt assert would abort.
|
// decrypt assert would abort. A third, locked too, names an output the tx doesn't have.
|
||||||
class WalletNoteCacheUndecryptable : public WalletNoteCache {
|
class WalletNoteCacheUndecryptable : public WalletNoteCache {
|
||||||
protected:
|
protected:
|
||||||
uint256 txBad;
|
uint256 txBad;
|
||||||
@@ -482,12 +482,14 @@ protected:
|
|||||||
CWalletTx wtx(&wallet, tx);
|
CWalletTx wtx(&wallet, tx);
|
||||||
wtx.mapSaplingNoteData[SaplingOutPoint(txBad, 0)] = SaplingNoteData(ivkA, nf);
|
wtx.mapSaplingNoteData[SaplingOutPoint(txBad, 0)] = SaplingNoteData(ivkA, nf);
|
||||||
wtx.mapSaplingNoteData[SaplingOutPoint(txBad, 1)] = SaplingNoteData(ivkA);
|
wtx.mapSaplingNoteData[SaplingOutPoint(txBad, 1)] = SaplingNoteData(ivkA);
|
||||||
|
wtx.mapSaplingNoteData[SaplingOutPoint(txBad, 7)] = SaplingNoteData(ivkA);
|
||||||
EXPECT_TRUE(wallet.AddToWallet(wtx, true, nullptr));
|
EXPECT_TRUE(wallet.AddToWallet(wtx, true, nullptr));
|
||||||
depthOf[txBad] = 4;
|
depthOf[txBad] = 4;
|
||||||
txids.push_back(txBad);
|
txids.push_back(txBad);
|
||||||
|
|
||||||
AddTx(MakeTx({}, {nf}), 4);
|
AddTx(MakeTx({}, {nf}), 4);
|
||||||
wallet.LockNote(SaplingOutPoint(txBad, 1));
|
wallet.LockNote(SaplingOutPoint(txBad, 1));
|
||||||
|
wallet.LockNote(SaplingOutPoint(txBad, 7));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -770,3 +772,46 @@ TEST_F(WalletNoteCache, SpendabilityNotCached) {
|
|||||||
EXPECT_EQ(2u, Filtered({paB}, 1, INT_MAX, true, true, false).size());
|
EXPECT_EQ(2u, Filtered({paB}, 1, INT_MAX, true, true, false).size());
|
||||||
EXPECT_EQ(fills, wallet.nSaplingNoteCacheFills);
|
EXPECT_EQ(fills, wallet.nSaplingNoteCacheFills);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The startup warm-up decrypts every note the wallet holds: spent, locked, unconfirmed and stale ones too
|
||||||
|
// (z_listreceivedbyaddress asks for spent notes), after which a walk decrypts nothing.
|
||||||
|
TEST_F(WalletNoteCache, WarmUpFillsEveryNote) {
|
||||||
|
LOCK2(cs_main, wallet.cs_wallet);
|
||||||
|
size_t total = 0;
|
||||||
|
for (const auto& p : wallet.mapWallet)
|
||||||
|
total += p.second.mapSaplingNoteData.size();
|
||||||
|
ASSERT_EQ(11u, total); // tx1 2, tx2 1, tx3 3, tx4 1, unmined 1, stale 1, tx6 1, tx7 1
|
||||||
|
|
||||||
|
wallet.ClearPlaintextCache();
|
||||||
|
const uint64_t base = wallet.nSaplingNoteCacheFills;
|
||||||
|
wallet.WarmSaplingNoteCache();
|
||||||
|
EXPECT_EQ(total, wallet.CachedNotes());
|
||||||
|
EXPECT_EQ(base + total, wallet.nSaplingNoteCacheFills);
|
||||||
|
|
||||||
|
wallet.WarmSaplingNoteCache();
|
||||||
|
EXPECT_EQ(base + total, wallet.nSaplingNoteCacheFills) << "nothing left to do";
|
||||||
|
Filtered({}, 0, INT_MAX, false, false, false);
|
||||||
|
Filtered({paC}, 11, INT_MAX, true, true, true);
|
||||||
|
EXPECT_EQ(base + total, wallet.nSaplingNoteCacheFills) << "walks find everything cached";
|
||||||
|
}
|
||||||
|
|
||||||
|
// The warm-up never aborts: notes that don't decrypt, or name an output the tx doesn't have, are left
|
||||||
|
// unfilled and everything else is filled.
|
||||||
|
TEST_F(WalletNoteCacheUndecryptable, WarmUpSkipsBadNotes) {
|
||||||
|
LOCK2(cs_main, wallet.cs_wallet);
|
||||||
|
wallet.ClearPlaintextCache();
|
||||||
|
wallet.WarmSaplingNoteCache();
|
||||||
|
const auto& bad = wallet.mapWallet.at(txBad).mapSaplingNoteData;
|
||||||
|
ASSERT_EQ(3u, bad.size());
|
||||||
|
for (const auto& item : bad)
|
||||||
|
EXPECT_FALSE(static_cast<bool>(item.second.cachedPlaintext)) << item.first.n;
|
||||||
|
size_t cached = 0, total = 0;
|
||||||
|
for (const auto& p : wallet.mapWallet) {
|
||||||
|
for (const auto& item : p.second.mapSaplingNoteData) {
|
||||||
|
total++;
|
||||||
|
cached += item.second.cachedPlaintext ? 1 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EXPECT_EQ(total - 3, cached);
|
||||||
|
EXPECT_EQ(7u, Filtered({}, 1, INT_MAX, true, false, true).size()) << "the bad notes are spent or locked";
|
||||||
|
}
|
||||||
|
|||||||
@@ -513,6 +513,8 @@ std::string HelpMessage(HelpMessageMode mode)
|
|||||||
// If this is used incorrectly (-rescanheight too large), then the local wallet may attempt to spend funds which it does not have witness data about
|
// If this is used incorrectly (-rescanheight too large), then the local wallet may attempt to spend funds which it does not have witness data about
|
||||||
// which will cause a "missing inputs" error when added to the mempool. Rescanning from correct height will fix this.
|
// which will cause a "missing inputs" error when added to the mempool. Rescanning from correct height will fix this.
|
||||||
strUsage += HelpMessageOpt("-keepnotewitnesscache", _("Keep partial Sapling Note Witness cache. Must be used with -rescanheight to find missing cache items."));
|
strUsage += HelpMessageOpt("-keepnotewitnesscache", _("Keep partial Sapling Note Witness cache. Must be used with -rescanheight to find missing cache items."));
|
||||||
|
if (showDebug)
|
||||||
|
strUsage += HelpMessageOpt("-notecachewarmup", "Decrypt all wallet notes into memory at startup, so the first balance call after a restart doesn't (default: 1)");
|
||||||
strUsage += HelpMessageOpt("-rescan", _("Rescan the block chain for missing wallet transactions") + " " + _("on startup"));
|
strUsage += HelpMessageOpt("-rescan", _("Rescan the block chain for missing wallet transactions") + " " + _("on startup"));
|
||||||
strUsage += HelpMessageOpt("-rescanheight", _("Rescan from specified height when rescan=1 on startup"));
|
strUsage += HelpMessageOpt("-rescanheight", _("Rescan from specified height when rescan=1 on startup"));
|
||||||
strUsage += HelpMessageOpt("-salvagewallet", _("Attempt to recover private keys from a corrupt wallet.dat") + " " + _("on startup"));
|
strUsage += HelpMessageOpt("-salvagewallet", _("Attempt to recover private keys from a corrupt wallet.dat") + " " + _("on startup"));
|
||||||
@@ -2706,6 +2708,14 @@ bool AppInit2(boost::thread_group& threadGroup, CScheduler& scheduler)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Decrypt the wallet's notes into the in-memory plaintext cache now, while RPC is still in warmup
|
||||||
|
// and before the wallet notification thread (started below) can want cs_wallet. Otherwise the
|
||||||
|
// first balance call after a restart decrypts them all under cs_main.
|
||||||
|
if (GetBoolArg("-notecachewarmup", true) && !ShutdownRequested()) {
|
||||||
|
uiInterface.InitMessage(_("Decrypting wallet notes..."));
|
||||||
|
pwalletMain->WarmSaplingNoteCache();
|
||||||
|
}
|
||||||
|
|
||||||
pwalletMain->SetBroadcastTransactions(GetBoolArg("-walletbroadcast", true));
|
pwalletMain->SetBroadcastTransactions(GetBoolArg("-walletbroadcast", true));
|
||||||
} // (!fDisableWallet)
|
} // (!fDisableWallet)
|
||||||
#else // ENABLE_WALLET
|
#else // ENABLE_WALLET
|
||||||
|
|||||||
@@ -5743,6 +5743,43 @@ unsigned int CWallet::FillSaplingNoteCache(const std::vector<SaplingNoteToDecryp
|
|||||||
return nFilled;
|
return nFilled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wallet.dat doesn't store the plaintext, so without this the first balance call after a restart decrypts
|
||||||
|
// the whole wallet under cs_main (~0.5ms a note). init runs it before RPC leaves warmup and before the
|
||||||
|
// wallet notification thread starts, so nothing waits on cs_wallet meanwhile. Unlike GetFilteredNotes it
|
||||||
|
// never asserts, so a corrupt note can't stop the node from starting (or from serving the key-export RPCs
|
||||||
|
// allowed during warmup); GetFilteredNotes still asserts on such a note, as before.
|
||||||
|
void CWallet::WarmSaplingNoteCache()
|
||||||
|
{
|
||||||
|
const std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now();
|
||||||
|
LOCK(cs_wallet);
|
||||||
|
std::vector<SaplingNoteToDecrypt> uncached;
|
||||||
|
unsigned int nNotes = 0, nBad = 0;
|
||||||
|
for (auto& p : mapWallet) {
|
||||||
|
CWalletTx& wtx = p.second;
|
||||||
|
for (auto& item : wtx.mapSaplingNoteData) {
|
||||||
|
nNotes++;
|
||||||
|
if (item.second.cachedPlaintext)
|
||||||
|
continue;
|
||||||
|
if (item.first.n >= wtx.vShieldedOutput.size()) {
|
||||||
|
LogPrint("zrpcunsafe", "%s: note %s:%u has no such output\n", __func__, item.first.hash.ToString(), item.first.n);
|
||||||
|
nBad++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
uncached.push_back(SaplingNoteToDecrypt { &item.second, &wtx.vShieldedOutput[item.first.n] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const unsigned int nFilled = uncached.empty() ? 0 : FillSaplingNoteCache(uncached);
|
||||||
|
for (const SaplingNoteToDecrypt& n : uncached) {
|
||||||
|
if (!n.nd->cachedPlaintext) {
|
||||||
|
LogPrint("zrpcunsafe", "%s: a note of output %s does not decrypt under its ivk\n", __func__, n.output->cm.ToString());
|
||||||
|
nBad++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const int64_t nMs = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - start).count();
|
||||||
|
LogPrintf("%s: decrypted %u of %u Sapling notes in %dms%s\n", __func__, nFilled, nNotes, nMs,
|
||||||
|
nBad ? strprintf("; %u could not be decrypted (-debug=zrpcunsafe lists them)", nBad) : "");
|
||||||
|
}
|
||||||
|
|
||||||
// Replaces tx_height() on the wallet's note paths. tx_height() re-read the tx from disk through
|
// Replaces tx_height() on the wallet's note paths. tx_height() re-read the tx from disk through
|
||||||
// GetTransaction, once per wallet tx (or per returned note) under LOCK2, and on a -txindex=0 node it
|
// GetTransaction, once per wallet tx (or per returned note) under LOCK2, and on a -txindex=0 node it
|
||||||
// never found a fully shielded tx, so those notes were silently skipped. The depth already locates
|
// never found a fully shielded tx, so those notes were silently skipped. The depth already locates
|
||||||
|
|||||||
@@ -1497,6 +1497,9 @@ public:
|
|||||||
/** Notes FillSaplingNoteCache has decrypted since startup (FindMySaplingNotes' fills not counted).
|
/** Notes FillSaplingNoteCache has decrypted since startup (FindMySaplingNotes' fills not counted).
|
||||||
* Guarded by cs_wallet. */
|
* Guarded by cs_wallet. */
|
||||||
uint64_t nSaplingNoteCacheFills = 0;
|
uint64_t nSaplingNoteCacheFills = 0;
|
||||||
|
/** Decrypts every wallet note whose plaintext isn't cached yet (all of them after wallet.dat is loaded).
|
||||||
|
* Never asserts: a note that won't decrypt is logged and left unfilled. Run at startup. */
|
||||||
|
void WarmSaplingNoteCache();
|
||||||
|
|
||||||
void GetFilteredNotes(std::vector<SaplingNoteEntry>& saplingEntries,
|
void GetFilteredNotes(std::vector<SaplingNoteEntry>& saplingEntries,
|
||||||
std::string address,
|
std::string address,
|
||||||
|
|||||||
Reference in New Issue
Block a user