diff --git a/src/gtest/test_wallet_notecache.cpp b/src/gtest/test_wallet_notecache.cpp index bc2ea9c30..9782c0ddb 100644 --- a/src/gtest/test_wallet_notecache.cpp +++ b/src/gtest/test_wallet_notecache.cpp @@ -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 // 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 { protected: uint256 txBad; @@ -482,12 +482,14 @@ protected: CWalletTx wtx(&wallet, tx); wtx.mapSaplingNoteData[SaplingOutPoint(txBad, 0)] = SaplingNoteData(ivkA, nf); wtx.mapSaplingNoteData[SaplingOutPoint(txBad, 1)] = SaplingNoteData(ivkA); + wtx.mapSaplingNoteData[SaplingOutPoint(txBad, 7)] = SaplingNoteData(ivkA); EXPECT_TRUE(wallet.AddToWallet(wtx, true, nullptr)); depthOf[txBad] = 4; txids.push_back(txBad); AddTx(MakeTx({}, {nf}), 4); 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(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(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"; +} diff --git a/src/init.cpp b/src/init.cpp index 42a5a6dbc..154a9029e 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -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 // 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.")); + 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("-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")); @@ -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)); } // (!fDisableWallet) #else // ENABLE_WALLET diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index dd2d91556..b6e22e492 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -5743,6 +5743,43 @@ unsigned int CWallet::FillSaplingNoteCache(const std::vector 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::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 // 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 diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index dc421fac0..47e39bc75 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -1497,6 +1497,9 @@ public: /** Notes FillSaplingNoteCache has decrypted since startup (FindMySaplingNotes' fills not counted). * Guarded by cs_wallet. */ 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& saplingEntries, std::string address,