From e27d61320d126cafbfc8d12a4fafbab86d351490 Mon Sep 17 00:00:00 2001 From: Gilcimar Duarte Date: Mon, 3 Aug 2026 09:44:47 -0300 Subject: [PATCH] Add wallet recovery balance audit --- src/gui.cpp | 54 +++++++++++++++++++++++++++-- src/main.cpp | 87 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.h | 31 +++++++++++++++++ src/main_gui.cpp | 12 ++++++- src/selftest.cpp | 36 ++++++++++++++++++++ src/walletcmd.cpp | 59 ++++++++++++++++++++++++++++++++ src/walletcmd.h | 4 +++ 7 files changed, 280 insertions(+), 3 deletions(-) diff --git a/src/gui.cpp b/src/gui.cpp index e800244..051d801 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -65,6 +65,8 @@ static std::string g_sendStatus; static char g_backupPath[512] = {}; static std::string g_backupStatus; static int64 g_lastWalletBackup = 0; +static WalletRecoveryAudit g_recoveryAudit; +static int64 g_recoveryAuditTime = 0; static bool g_walletSafetyLoaded = false; static char g_participantPool[256] = {}; static char g_poolName[128] = {}; @@ -184,6 +186,15 @@ static void LoadWalletSafetyState() g_walletSafetyLoaded = true; } +static void RefreshRecoveryAudit(bool fForce=false) +{ + int64 nNow = GetTime(); + if (!fForce && g_recoveryAuditTime != 0 && nNow - g_recoveryAuditTime < 5) + return; + g_recoveryAudit = GetWalletRecoveryAudit(); + g_recoveryAuditTime = nNow; +} + static int KeyPoolCount() { int n = 0; @@ -267,7 +278,11 @@ static void RefreshWallet() g_lastWalletRefresh = GetTime(); } -void MainFrameRepaint() { g_needRefresh = true; } +void MainFrameRepaint() +{ + g_needRefresh = true; + g_recoveryAuditTime = 0; +} // DateTimeStr moved to util.cpp: main.cpp logs block times with it, so it is // needed by builds that have no GUI at all. @@ -967,6 +982,7 @@ static void RestoreThread(std::string strPhrase) "Check the words and their order.", nDerived); } g_restoreRunning.store(false); + g_recoveryAuditTime = 0; g_needRefresh = true; } @@ -1013,6 +1029,7 @@ static void DrawCreatePhraseDialog() if (SetHDSeedFromMnemonic(g_pendingMnemonic, strError)) { TopUpKeyPool(); + g_recoveryAuditTime = 0; g_phraseStatus = "Recovery phrase created. New addresses come from it."; } else @@ -1133,8 +1150,9 @@ static void DrawWalletSafetyDialog() { if (!g_showWalletSafety) return; LoadWalletSafetyState(); + RefreshRecoveryAudit(); - ImGui::SetNextWindowSize(ImVec2(650.0f, 390.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(650.0f, 450.0f), ImGuiCond_Always); ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Always, ImVec2(0.5f, 0.5f)); if (ImGui::Begin("Wallet Safety", &g_showWalletSafety, @@ -1221,6 +1239,38 @@ static void DrawWalletSafetyDialog() ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.25f, 1.0f), "No recovery phrase. Only a file backup can rebuild this wallet."); + ImGui::Text("Phrase-backed spendable balance: %s BTF", + FmtMoney(g_recoveryAudit.nRecoverableCredit).c_str()); + ImGui::Text("Wallet.dat-only spendable balance: %s BTF", + FmtMoney(g_recoveryAudit.nLegacyCredit).c_str()); + ImGui::Text("Phrase-backed immature mining rewards: %s BTF", + FmtMoney(g_recoveryAudit.nRecoverableImmatureCredit).c_str()); + ImGui::Text("Wallet.dat-only immature mining rewards: %s BTF", + FmtMoney(g_recoveryAudit.nLegacyImmatureCredit).c_str()); + if (!g_recoveryAudit.fDeriveComplete) + { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.75f, 0.25f, 1.0f)); + ImGui::TextWrapped("%s", g_recoveryAudit.strDeriveError.c_str()); + ImGui::PopStyleColor(); + } + if (g_recoveryAudit.nLegacyCredit > 0 || + g_recoveryAudit.nLegacyImmatureCredit > 0) + { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.75f, 0.25f, 1.0f)); + ImGui::TextWrapped( + "Some coins are on keys the phrase does not reproduce. " + "Keep wallet.dat backups until that balance has been moved to a " + "phrase-backed address."); + ImGui::PopStyleColor(); + } + else if (HaveHDSeed() && + g_recoveryAudit.nRecoverableCredit + + g_recoveryAudit.nRecoverableImmatureCredit > 0) + { + ImGui::TextColored(ImVec4(0.55f, 1.0f, 0.6f, 1.0f), + "All known wallet balance is covered by the phrase."); + } + if (!HaveHDSeed()) { if (ImGui::Button("Create recovery phrase", ImVec2(200.0f, 0.0f))) diff --git a/src/main.cpp b/src/main.cpp index 1b09a1a..75647ba 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3660,6 +3660,93 @@ bool BitcoinMiner(int nThreadId) // +WalletRecoveryAudit GetWalletRecoveryAudit() +{ + WalletRecoveryAudit audit; + set > setDerivedPubKeys; + + CRITICAL_BLOCK(cs_keyPool) + { + audit.fHaveSeed = HaveHDSeed(); + audit.nDerivedKnown = nHDNext; + if (audit.fHaveSeed) + { + string strError; + for (unsigned int i = 0; i < nHDNext; i++) + { + CKey key; + if (!DeriveHDKey(i, key, strError)) + { + audit.fDeriveComplete = false; + audit.strDeriveError = strprintf("derivation failed at index %u: %s", + i, strError.c_str()); + break; + } + setDerivedPubKeys.insert(key.GetPubKey()); + } + } + } + + CRITICAL_BLOCK(cs_mapWallet) + { + for (map::iterator it = mapWallet.begin(); it != mapWallet.end(); ++it) + { + CWalletTx& wtx = (*it).second; + if (!wtx.IsFinal() || wtx.fSpent) + continue; + bool fImmature = wtx.IsCoinBase() && wtx.GetBlocksToMaturity() > 0; + + bool fTxRecoverable = false; + bool fTxLegacy = false; + for (int i = 0; i < (int)wtx.vout.size(); i++) + { + const CTxOut& txout = wtx.vout[i]; + if (!txout.IsMine()) + continue; + + vector vchPubKey; + bool fRecoverable = false; + if (audit.fHaveSeed && ExtractPubKey(txout.scriptPubKey, true, vchPubKey)) + fRecoverable = setDerivedPubKeys.count(vchPubKey) > 0; + + if (fRecoverable) + { + if (fImmature) + audit.nRecoverableImmatureCredit += txout.nValue; + else + audit.nRecoverableCredit += txout.nValue; + fTxRecoverable = true; + } + else + { + if (fImmature) + audit.nLegacyImmatureCredit += txout.nValue; + else + audit.nLegacyCredit += txout.nValue; + fTxLegacy = true; + } + } + + if (fTxRecoverable) + { + if (fImmature) + audit.nRecoverableImmatureTx++; + else + audit.nRecoverableTx++; + } + if (fTxLegacy) + { + if (fImmature) + audit.nLegacyImmatureTx++; + else + audit.nLegacyTx++; + } + } + } + + return audit; +} + int64 GetBalance() { int64 nStart, nEnd; diff --git a/src/main.h b/src/main.h index 768f1c7..4886f55 100644 --- a/src/main.h +++ b/src/main.h @@ -217,6 +217,37 @@ extern CCriticalSection cs_mapTransactions; bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv); bool SendMessages(CNode* pto); int64 GetBalance(); +struct WalletRecoveryAudit +{ + bool fHaveSeed; + bool fDeriveComplete; + unsigned int nDerivedKnown; + int nRecoverableTx; + int nLegacyTx; + int nRecoverableImmatureTx; + int nLegacyImmatureTx; + int64 nRecoverableCredit; + int64 nLegacyCredit; + int64 nRecoverableImmatureCredit; + int64 nLegacyImmatureCredit; + string strDeriveError; + + WalletRecoveryAudit() + { + fHaveSeed = false; + fDeriveComplete = true; + nDerivedKnown = 0; + nRecoverableTx = 0; + nLegacyTx = 0; + nRecoverableImmatureTx = 0; + nLegacyImmatureTx = 0; + nRecoverableCredit = 0; + nLegacyCredit = 0; + nRecoverableImmatureCredit = 0; + nLegacyImmatureCredit = 0; + } +}; +WalletRecoveryAudit GetWalletRecoveryAudit(); bool CreateTransaction(CScript scriptPubKey, int64 nValue, CWalletTx& txNew, int64& nFeeRequiredRet); bool CommitTransactionSpent(const CWalletTx& wtxNew); bool SendMoney(CScript scriptPubKey, int64 nValue, CWalletTx& wtxNew); diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 32fcdef..1b72813 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -93,7 +93,10 @@ static void PrintUsage() printf(" /restoredepth=N (with /restorephrase: derive at least N\n"); printf(" addresses before giving up)\n"); printf(" /showderived=N (list the first N addresses a phrase\n"); - printf(" produces, without installing it)\n"); + printf(" installed in this wallet derives)\n"); + printf(" /recoveryaudit (show how much spendable balance is\n"); + printf(" covered by the recovery phrase;\n"); + printf(" exits 2 when wallet.dat is still needed)\n"); printf(" /rescan (walk the chain for coins this wallet owns\n"); printf(" but never recorded, then exit)\n"); printf("\n"); @@ -385,6 +388,13 @@ int main(int argc, char* argv[]) return nRet; } + if (arg(argc,argv,"/recoveryaudit") || arg(argc,argv,"-recoveryaudit")) + { + int nRet = CmdRecoveryAudit(); + DBFlush(true); + return nRet; + } + if (arg(argc,argv,"/newphrase") || arg(argc,argv,"-newphrase")) { int nRet = CmdNewPhrase(); diff --git a/src/selftest.cpp b/src/selftest.cpp index 67f96f8..df55bcd 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -336,6 +336,7 @@ static int RunWalletHDSelfTest() // The address the wallet was showing before any of this. It has to // survive under a name that says it is not covered by the phrase. + std::vector vchPreSeedKey = keyUser.GetPubKey(); std::string strPreSeedAddr = PubKeyToAddress(keyUser.GetPubKey()); nFail += Check(SetHDSeedFromMnemonic(strPhraseA, strError), @@ -449,6 +450,41 @@ static int RunWalletHDSelfTest() } nFail += Check(fStored, "every derived pooled key is stored in wallet.dat") ? 0 : 1; nFail += Check(fFromSeed, "every pooled key came from the seed, not from chance") ? 0 : 1; + + // A recovery phrase is only useful if the wallet can tell the user + // what today's spendable balance would actually come back from it. + CKey keyAuditDerived; + if (!DeriveHDKey(0, keyAuditDerived, strError)) + throw std::runtime_error("audit derivation failed: " + strError); + + CWalletTx wtxLegacy; + wtxLegacy.vout.push_back(CTxOut(5 * COIN, CScript() << vchPreSeedKey << OP_CHECKSIG)); + CWalletTx wtxDerived; + wtxDerived.vout.push_back(CTxOut(7 * COIN, CScript() << keyAuditDerived.GetPubKey() << OP_CHECKSIG)); + CWalletTx wtxImmatureLegacy; + wtxImmatureLegacy.vin.push_back(CTxIn()); + wtxImmatureLegacy.vout.push_back(CTxOut(11 * COIN, CScript() << vchPreSeedKey << OP_CHECKSIG)); + + CRITICAL_BLOCK(cs_mapWallet) + { + mapWallet.clear(); + mapWallet[wtxLegacy.GetHash()] = wtxLegacy; + mapWallet[wtxDerived.GetHash()] = wtxDerived; + mapWallet[wtxImmatureLegacy.GetHash()] = wtxImmatureLegacy; + } + + WalletRecoveryAudit audit = GetWalletRecoveryAudit(); + nFail += Check(audit.fHaveSeed, "the recovery audit reports the phrase") ? 0 : 1; + nFail += Check(audit.nLegacyCredit == 5 * COIN, + "the recovery audit finds wallet.dat-only balance") ? 0 : 1; + nFail += Check(audit.nRecoverableCredit == 7 * COIN, + "the recovery audit finds phrase-backed balance") ? 0 : 1; + nFail += Check(audit.nLegacyTx == 1 && audit.nRecoverableTx == 1, + "the recovery audit counts legacy and phrase-backed transactions") ? 0 : 1; + nFail += Check(audit.nLegacyImmatureCredit == 11 * COIN, + "the recovery audit finds wallet.dat-only immature mining rewards") ? 0 : 1; + nFail += Check(audit.nLegacyImmatureTx == 1, + "the recovery audit counts wallet.dat-only immature mining rewards") ? 0 : 1; } catch (const std::exception& e) { diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index 6d5f32b..41b4ed2 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -272,6 +272,65 @@ int CmdShowDerived(int nCount) return 0; } +int CmdRecoveryAudit() +{ + AttachTerminal(); + + WalletRecoveryAudit audit = GetWalletRecoveryAudit(); + int64 nTotal = audit.nRecoverableCredit + audit.nLegacyCredit; + int64 nImmatureTotal = audit.nRecoverableImmatureCredit + audit.nLegacyImmatureCredit; + + printf("Wallet recovery audit\n"); + printf(" recovery phrase: %s\n", audit.fHaveSeed ? "present" : "not installed"); + if (audit.fHaveSeed) + printf(" derived keys known to this wallet: %u\n", audit.nDerivedKnown); + if (!audit.fDeriveComplete) + printf(" derivation warning: %s\n", audit.strDeriveError.c_str()); + printf(" total spendable balance: %s BTF\n", FormatMoney(nTotal).c_str()); + printf(" covered by recovery phrase: %s BTF (%d transaction(s))\n", + FormatMoney(audit.nRecoverableCredit).c_str(), audit.nRecoverableTx); + printf(" wallet.dat-only balance: %s BTF (%d transaction(s))\n", + FormatMoney(audit.nLegacyCredit).c_str(), audit.nLegacyTx); + printf(" immature mining rewards: %s BTF\n", FormatMoney(nImmatureTotal).c_str()); + printf(" phrase-backed immature: %s BTF (%d transaction(s))\n", + FormatMoney(audit.nRecoverableImmatureCredit).c_str(), + audit.nRecoverableImmatureTx); + printf(" wallet.dat-only immature: %s BTF (%d transaction(s))\n", + FormatMoney(audit.nLegacyImmatureCredit).c_str(), + audit.nLegacyImmatureTx); + + if (!audit.fHaveSeed) + { + printf("\n"); + printf("This wallet has no recovery phrase. A file backup is the only backup.\n"); + fflush(stdout); + return nTotal + nImmatureTotal > 0 ? 2 : 0; + } + if (!audit.fDeriveComplete) + { + printf("\n"); + printf("Warning: the audit could not derive every known phrase key, so coverage is incomplete.\n"); + fflush(stdout); + return 2; + } + if (audit.nLegacyCredit > 0 || audit.nLegacyImmatureCredit > 0) + { + printf("\n"); + printf("Warning: some coins are on keys the phrase does not reproduce.\n"); + printf("Keep wallet.dat backups until that balance has been moved to a phrase-backed address.\n"); + fflush(stdout); + return 2; + } + + printf("\n"); + if (nTotal + nImmatureTotal > 0) + printf("All known wallet balance is covered by the recovery phrase.\n"); + else + printf("No wallet balance found yet.\n"); + fflush(stdout); + return 0; +} + // Spend, from the command line. // // SendMoney() has been in this tree since 0.1.0 and only the window ever called diff --git a/src/walletcmd.h b/src/walletcmd.h index 89834d8..796356b 100644 --- a/src/walletcmd.h +++ b/src/walletcmd.h @@ -40,4 +40,8 @@ int CmdNewAddress(); // Diagnostic: print the first nCount addresses this wallet's phrase derives. int CmdShowDerived(int nCount); +// Diagnostic: print whether the currently spendable wallet balance is covered +// by the installed recovery phrase or still depends on wallet.dat-only keys. +int CmdRecoveryAudit(); + #endif