From a2726cad9e9e11c73ae341ec627a696cdeb63edc Mon Sep 17 00:00:00 2001 From: mtg0001 <106211915+mtg0001@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:15:15 -0300 Subject: Add wallet keypool self-test (#91) Co-authored-by: Gilcimar Duarte --- src/selftest.cpp | 275 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 src/selftest.cpp (limited to 'src/selftest.cpp') diff --git a/src/selftest.cpp b/src/selftest.cpp new file mode 100644 index 0000000..d649b23 --- /dev/null +++ b/src/selftest.cpp @@ -0,0 +1,275 @@ +// Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. +// +// Integration self-tests that need the real wallet/database code linked into +// the node. These are intentionally run behind an explicit command-line flag +// and against a temporary data directory. + +#include "headers_core.h" +#include "selftest.h" + +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#include +#include +#include +#endif + +static std::string HexStrLocal(const std::vector& v) +{ + static const char* hex = "0123456789abcdef"; + std::string out; + out.reserve(v.size() * 2); + for (unsigned char c : v) + { + out.push_back(hex[c >> 4]); + out.push_back(hex[c & 0x0f]); + } + return out; +} + +static bool MakeTempDir(std::string& out) +{ +#ifdef _WIN32 + char base[MAX_PATH + 1] = {0}; + if (!GetTempPathA(MAX_PATH, base)) + return false; + for (int i = 0; i < 100; i++) + { + std::string path = strprintf("%sbitflash-selftest-%lu-%lld-%d", + base, (unsigned long)GetCurrentProcessId(), (long long)GetTime(), i); + if (CreateDirectoryA(path.c_str(), NULL)) + { + out = path; + return true; + } + } + return false; +#else + char tmpl[256]; + snprintf(tmpl, sizeof(tmpl), "/tmp/bitflash-selftest-%ld-XXXXXX", (long)getpid()); + char* p = mkdtemp(tmpl); + if (!p) + return false; + out = p; + return true; +#endif +} + +static bool GetCurrentDir(std::string& out) +{ +#ifdef _WIN32 + char buf[MAX_PATH + 1] = {0}; + DWORD n = GetCurrentDirectoryA(MAX_PATH, buf); + if (n == 0 || n > MAX_PATH) + return false; + out = buf; + return true; +#else + char buf[4096]; + if (!getcwd(buf, sizeof(buf))) + return false; + out = buf; + return true; +#endif +} + +static bool SetCurrentDir(const std::string& path) +{ +#ifdef _WIN32 + return SetCurrentDirectoryA(path.c_str()) != 0; +#else + return chdir(path.c_str()) == 0; +#endif +} + +static void RemoveTree(const std::string& path) +{ +#ifdef _WIN32 + DWORD attr = GetFileAttributesA(path.c_str()); + if (attr == INVALID_FILE_ATTRIBUTES) + return; + if (!(attr & FILE_ATTRIBUTE_DIRECTORY)) + { + DeleteFileA(path.c_str()); + return; + } + + WIN32_FIND_DATAA findData; + std::string pattern = path + "\\*"; + HANDLE hFind = FindFirstFileA(pattern.c_str(), &findData); + if (hFind != INVALID_HANDLE_VALUE) + { + do + { + std::string name = findData.cFileName; + if (name == "." || name == "..") + continue; + RemoveTree(path + "\\" + name); + } + while (FindNextFileA(hFind, &findData)); + FindClose(hFind); + } + RemoveDirectoryA(path.c_str()); +#else + struct stat st; + if (lstat(path.c_str(), &st) != 0) + return; + if (!S_ISDIR(st.st_mode)) + { + unlink(path.c_str()); + return; + } + + DIR* dir = opendir(path.c_str()); + if (dir) + { + struct dirent* ent; + while ((ent = readdir(dir)) != NULL) + { + std::string name = ent->d_name; + if (name == "." || name == "..") + continue; + RemoveTree(path + "/" + name); + } + closedir(dir); + } + rmdir(path.c_str()); +#endif +} + +static bool WalletHasPrivateKey(const std::vector& vchPubKey) +{ + CPrivKey priv; + return CWalletDB("r").ReadKey(vchPubKey, priv) && !priv.empty(); +} + +static bool Check(bool condition, const char* message) +{ + if (condition) + { + printf(" ok %s\n", message); + return true; + } + printf(" FAIL %s\n", message); + return false; +} + +static int RunWalletKeyPoolSelfTest() +{ + printf("wallet-keypool self-test\n"); + + std::string tmp; + std::string cwd; + if (!MakeTempDir(tmp)) + { + printf(" FAIL could not create a temporary data directory\n"); + return 1; + } + if (!GetCurrentDir(cwd) || !SetCurrentDir(tmp)) + { + printf(" FAIL could not move into the temporary data directory\n"); + RemoveTree(tmp); + return 1; + } + + int nFail = 0; + strSetDataDir = tmp; + printf(" temp datadir: %s\n", tmp.c_str()); + + try + { + if (!LoadWallet()) + throw std::runtime_error("LoadWallet failed"); + + TopUpKeyPool(); + int nInitialPoolSize = 0; + CRITICAL_BLOCK(cs_keyPool) + nInitialPoolSize = (int)mapKeyPool.size(); + nFail += Check(nInitialPoolSize == KEYPOOL_SIZE, "TopUpKeyPool fills the pool") ? 0 : 1; + + bool fPooledKeysStored = true; + CRITICAL_BLOCK(cs_keyPool) + { + for (map >::const_iterator mi = mapKeyPool.begin(); + mi != mapKeyPool.end(); ++mi) + if (!WalletHasPrivateKey(mi->second)) + fPooledKeysStored = false; + } + nFail += Check(fPooledKeysStored, "every pooled public key has a stored private key") ? 0 : 1; + + std::vector > drawn; + std::mutex drawnMutex; + const int nThreads = 8; + const int nDrawsPerThread = 32; + std::vector threads; + + for (int t = 0; t < nThreads; t++) + { + threads.emplace_back([&drawn, &drawnMutex, nDrawsPerThread]() { + for (int i = 0; i < nDrawsPerThread; i++) + { + std::vector key = GetKeyFromPool(); + std::lock_guard lock(drawnMutex); + drawn.push_back(key); + } + }); + } + for (std::thread& t : threads) + t.join(); + + nFail += Check((int)drawn.size() == nThreads * nDrawsPerThread, + "all concurrent draws returned a key") ? 0 : 1; + + std::set seen; + bool fUnique = true; + bool fAllStored = true; + for (const std::vector& key : drawn) + { + if (!seen.insert(HexStrLocal(key)).second) + fUnique = false; + if (!WalletHasPrivateKey(key)) + fAllStored = false; + } + + nFail += Check(fUnique, "concurrent draws never return the same key") ? 0 : 1; + nFail += Check(fAllStored, "each drawn key was already stored in wallet.dat") ? 0 : 1; + int nPoolSize = 0; + CRITICAL_BLOCK(cs_keyPool) + nPoolSize = (int)mapKeyPool.size(); + nFail += Check(nPoolSize == KEYPOOL_SIZE, "drawing leaves the key pool topped up") ? 0 : 1; + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + DBFlush(true); + SetCurrentDir(cwd); + RemoveTree(tmp); + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + return nFail == 0 ? 0 : 1; +} + +int RunSelfTest(const std::string& name) +{ + if (name == "wallet-keypool") + return RunWalletKeyPoolSelfTest(); + + printf("Unknown self-test '%s'\n", name.c_str()); + printf("Known self-tests: wallet-keypool\n"); + return 1; +} -- cgit v1.2.3 From 224c2dd1d1b2bfb9fd3e089d23fd4e739e49a106 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Sat, 1 Aug 2026 15:24:34 -0300 Subject: diag: make the self-test report where the person running it can see (#92) On Windows the self-test from #91 printed nothing. printf in this tree is OutputDebugStringF, which writes to the data directory debug.log, and the Windows binary is linked -mwindows with no console. A failure was still caught, since a non-zero return stops make, but it arrived with no way to tell which check failed. And because the first line printed before strSetDataDir pointed at the temporary directory, every line was appended to the real debug.log -- the file users are asked to paste into issues. The file now gets the real printf, the data directory is set before anything prints, and on Windows a console is borrowed only when stdout has nowhere to go. That last condition matters: the first version reopened stdout unconditionally and took the output away from `make tests > log`, which is the case that counts. --- src/selftest.cpp | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) (limited to 'src/selftest.cpp') diff --git a/src/selftest.cpp b/src/selftest.cpp index d649b23..abe1d15 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -8,6 +8,23 @@ #include "headers_core.h" #include "selftest.h" +// Test results go to the terminal, not to debug.log. +// +// printf in this tree is OutputDebugStringF, which writes into the data +// directory's debug.log and binds that path, once, on its first call. For a +// self-test that produced two bad outcomes at the same time. On Windows the +// binary is linked -mwindows and has no console, so `make tests` printed the +// bip32 results, echoed the self-test command, and then showed nothing at all -- +// a failure was still caught, because a non-zero return stops make, but it +// arrived with no way to tell which check failed. And because the first line +// printed before strSetDataDir was pointed at the temporary directory, every +// line landed in the developer's real debug.log: the same file users are asked +// to paste into issues. +// +// Undefining the macro here gives this file the real printf, so results reach +// stdout. Nothing else in the tree is affected. +#undef printf + #include #include #include @@ -163,6 +180,7 @@ static bool Check(bool condition, const char* message) static int RunWalletKeyPoolSelfTest() { + fflush(stdout); printf("wallet-keypool self-test\n"); std::string tmp; @@ -180,6 +198,9 @@ static int RunWalletKeyPoolSelfTest() } int nFail = 0; + // Before anything can open the database or resolve the log path: what the + // node itself prints during the test belongs in the temporary directory, + // and dies with it. The test's own results are on stdout, above. strSetDataDir = tmp; printf(" temp datadir: %s\n", tmp.c_str()); @@ -261,11 +282,42 @@ static int RunWalletKeyPoolSelfTest() RemoveTree(tmp); printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", nFail, nFail == 1 ? "" : "s"); + fflush(stdout); return nFail == 0 ? 0 : 1; } +// A -mwindows binary starts with no console attached, so stdout goes nowhere +// even when it was launched from a terminal. Borrow the caller's console for +// the duration of the self-test. Nothing to do on Linux, where stdout is +// already connected. +static void AttachTerminal() +{ +#ifdef _WIN32 + // Only when there is nowhere for stdout to go. If the caller redirected it + // to a file or a pipe -- which is what `make tests > log` and any CI does -- + // that handle is inherited and already works, and reopening it on CONOUT$ + // would take the output away from the file and put it on the screen. That + // was the first version of this fix, and it broke the one case that + // mattered. + HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE); + if (hOut != NULL && hOut != INVALID_HANDLE_VALUE) + return; + + if (AttachConsole(ATTACH_PARENT_PROCESS)) + { + // Return values ignored on purpose: if the reopen fails there is + // nowhere left to report it, and the exit status still carries the + // result. + freopen("CONOUT$", "w", stdout); + freopen("CONOUT$", "w", stderr); + } +#endif +} + int RunSelfTest(const std::string& name) { + AttachTerminal(); + if (name == "wallet-keypool") return RunWalletKeyPoolSelfTest(); -- cgit v1.2.3 From ce59d8c72c667ce638bd12f37db26d8b3408a5d2 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Sat, 1 Aug 2026 17:53:17 -0300 Subject: wallet: derive the key pool from a seed, so a phrase could bring it back (#93) Adds BIP32 derivation behind the wallet key pool, with no user-facing way to create a seed yet. wallet.dat gains hdmaster/hdchaincode/hdnext; the mnemonic is never stored. TopUpKeyPool derives m/n' when a seed exists and falls back to a random key, with a log line, if derivation fails. New -selftest=wallet-hd, wired into make tests on both platforms. --- src/Makefile | 3 +- src/db.cpp | 12 +++++ src/db.h | 27 ++++++++++ src/key.h | 34 ++++++++++++ src/main.cpp | 112 +++++++++++++++++++++++++++++++++++++++- src/main.h | 28 ++++++++++ src/main_gui.cpp | 18 +++++++ src/makefile.mingw | 3 +- src/selftest.cpp | 149 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 9 files changed, 382 insertions(+), 4 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/Makefile b/src/Makefile index d4b1837..bbdc9e2 100644 --- a/src/Makefile +++ b/src/Makefile @@ -50,7 +50,7 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ - obj/selftest.o + obj/selftest.o obj/bip32.o GUI_OBJS = obj/gui.o obj/main_gui.o @@ -131,3 +131,4 @@ test_bip32: test_bip32.cpp bip32.cpp bip32.h bip39_english.h tests: test_bip32 bitflash-node ./test_bip32 ./bitflash-node -selftest=wallet-keypool + ./bitflash-node -selftest=wallet-hd diff --git a/src/db.cpp b/src/db.cpp index d482c5a..294920b 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -639,6 +639,18 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) { ssValue >> vchDefaultKeyRet; } + else if (strType == "hdmaster") + { + ssValue >> vchHDMaster; + } + else if (strType == "hdchaincode") + { + ssValue >> vchHDChainCode; + } + else if (strType == "hdnext") + { + ssValue >> nHDNext; + } else if (strType == "pool") { int64 nIndex; diff --git a/src/db.h b/src/db.h index f51047e..01b8583 100644 --- a/src/db.h +++ b/src/db.h @@ -386,6 +386,33 @@ public: return Write(string("defaultkey"), vchPubKey); } + // --- Deterministic (BIP32) seed --------------------------------------- + // + // Stored as the master key and chain code rather than the mnemonic: the + // twelve words are the user's to write down, and keeping a copy of them in + // the file they are meant to protect defeats the point of having them. + // + // "hdnext" is the next child index to derive, so the sequence a restore + // reproduces is the same one this wallet handed out. + bool ReadHDMaster(vector& vchMasterRet, vector& vchChainCodeRet) + { + vchMasterRet.clear(); + vchChainCodeRet.clear(); + return Read(string("hdmaster"), vchMasterRet) + && Read(string("hdchaincode"), vchChainCodeRet); + } + + bool WriteHDMaster(const vector& vchMaster, const vector& vchChainCode) + { + return Write(string("hdmaster"), vchMaster) + && Write(string("hdchaincode"), vchChainCode); + } + + bool WriteHDNext(unsigned int nNext) + { + return Write(string("hdnext"), nNext); + } + // Keys generated ahead of time and not yet handed out. The private key of // each is already stored under its own "key" record by AddKey; a "pool" // record only says that this one is still unspoken for. diff --git a/src/key.h b/src/key.h index 26e1ae2..6cc2a24 100644 --- a/src/key.h +++ b/src/key.h @@ -86,6 +86,40 @@ public: return true; } + // Load a raw 32-byte scalar, the form BIP32 derivation produces. + // + // SetPrivKey above takes DER and cannot be used for this: handing it a + // scalar makes d2i_ECPrivateKey fail, and if the caller ignores the return + // value -- as one attempt at this did -- the key is left with no material + // at all and the address it produces has no private half. Coins paid there + // are gone. Hence a separate entry point rather than a looser SetPrivKey. + // + // The public half is computed here rather than read from anywhere: + // pub = G * secret. + bool SetSecret(const std::vector& vchSecret) + { + if (vchSecret.size() != 32) + return false; + + bool fOk = false; + BIGNUM* bn = BN_bin2bn(&vchSecret[0], 32, NULL); + EC_POINT* pubkey = NULL; + if (bn && EC_KEY_set_private_key(pkey, bn)) + { + const EC_GROUP* group = EC_KEY_get0_group(pkey); + pubkey = EC_POINT_new(group); + if (pubkey && + EC_POINT_mul(group, pubkey, bn, NULL, NULL, NULL) && + EC_KEY_set_public_key(pkey, pubkey)) + fOk = true; + } + if (pubkey) + EC_POINT_free(pubkey); + if (bn) + BN_clear_free(bn); // it held the secret + return fOk; + } + CPrivKey GetPrivKey() const { unsigned int nSize = i2d_ECPrivateKey(pkey, NULL); diff --git a/src/main.cpp b/src/main.cpp index 82a98d2..e5754e6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -14,6 +14,7 @@ #include #include #include +#include "bip32.h" #include "btfaddr.h" #include "btftunnel.h" #ifndef _WIN32 @@ -178,6 +179,66 @@ vector GenerateNewKey() CCriticalSection cs_keyPool; map > mapKeyPool; +vector vchHDMaster; +vector vchHDChainCode; +unsigned int nHDNext = 0; + +bool DeriveHDKey(unsigned int nIndex, CKey& keyRet, string& strErrorRet) +{ + if (!HaveHDSeed()) + { + strErrorRet = "no seed"; + return false; + } + + bitflash::BIP32PrivateNode parent; + parent.privateKey = vchHDMaster; + parent.chainCode = vchHDChainCode; + + bitflash::BIP32PrivateNode child; + if (!bitflash::BIP32DeriveHardenedChild(parent, nIndex, child, strErrorRet)) + return false; + + if (!keyRet.SetSecret(child.privateKey)) + { + strErrorRet = "derived scalar is not a usable key"; + return false; + } + return true; +} + +bool SetHDSeedFromMnemonic(const string& strMnemonic, string& strErrorRet) +{ + string strNormalized; + if (!bitflash::BIP39ValidateMnemonic(strMnemonic, strNormalized, strErrorRet)) + return false; + + vector vchSeed; + if (!bitflash::BIP39MnemonicToSeed(strNormalized, "", vchSeed, strErrorRet)) + return false; + + bitflash::BIP32PrivateNode master; + if (!bitflash::BIP32MasterFromSeed(vchSeed, master, strErrorRet)) + return false; + + CRITICAL_BLOCK(cs_keyPool) + { + // Written before the globals change, so a failed write leaves the + // wallet on the seed it already had rather than on one that exists + // only in memory. + if (!CWalletDB().WriteHDMaster(master.privateKey, master.chainCode) || + !CWalletDB().WriteHDNext(0)) + { + strErrorRet = "could not write the seed to wallet.dat"; + return false; + } + vchHDMaster = master.privateKey; + vchHDChainCode = master.chainCode; + nHDNext = 0; + } + return true; +} + void TopUpKeyPool() { CRITICAL_BLOCK(cs_keyPool) @@ -191,7 +252,30 @@ void TopUpKeyPool() nIndex = (--mapKeyPool.end())->first + 1; CKey key; - key.MakeNewKey(); + if (HaveHDSeed()) + { + // Derived, so a recovery phrase can reproduce this exact key. + // A derivation that fails is not a reason to stop filling the + // pool -- fall back to a random key, which is what a wallet + // without a seed uses anyway. It will not come back from the + // phrase, and that is better than a node that cannot mine. + string strError; + if (DeriveHDKey(nHDNext, key, strError)) + { + nHDNext++; + CWalletDB().WriteHDNext(nHDNext); + } + else + { + printf("TopUpKeyPool() : derivation at index %u failed (%s), " + "falling back to a random key\n", nHDNext, strError.c_str()); + key.MakeNewKey(); + } + } + else + { + key.MakeNewKey(); + } // AddKey writes the private key. Order matters: the key is in // wallet.dat before anything can point at it, so a crash between // the two writes costs an unused key, never a usable one. @@ -310,6 +394,32 @@ bool AddToWalletIfMine(const CTransaction& tx, const CBlock* pblock) return true; } +bool CanScanWalletTransactions(string& strErrorRet) +{ + strErrorRet.clear(); + + if (pindexGenesisBlock == NULL || pindexBest == NULL || hashBestChain == 0 || nBestHeight < 0) + { + strErrorRet = "The block index is not loaded, so the wallet scan cannot " + "prove whether imported or restored keys own coins."; + return false; + } + + if (nBestHeight == 0) + { + strErrorRet = "The local chain is only at height 0. A wallet scan would " + "inspect only the genesis block and could incorrectly " + "report zero transactions. Start the node and let it sync " + "before scanning. If this happened after copying a data " + "directory, keep wallet.dat safe and rebuild or re-download " + "the block data instead of trusting the copied database/ " + "environment."; + return false; + } + + return true; +} + int ScanForWalletTransactions(CBlockIndex* pindexStart) { // Walk the chain looking for transactions that belong to keys this wallet diff --git a/src/main.h b/src/main.h index 8d9b7e6..a04a4e3 100644 --- a/src/main.h +++ b/src/main.h @@ -141,6 +141,33 @@ static const int KEYPOOL_SIZE = 100; extern CCriticalSection cs_keyPool; extern map > mapKeyPool; +// --- Deterministic wallet (BIP32) ----------------------------------------- +// +// When a seed is present the key pool is derived from it -- m/0'/n, hardened, +// n increasing -- instead of being made of random keys. That is what lets a +// recovery phrase bring a wallet back: the same twelve words reproduce the same +// keys in the same order. +// +// A wallet without a seed keeps working exactly as before. Nothing here +// migrates an existing wallet on its own: the seed is created only when the +// user asks for a phrase and confirms they have written it down, because a +// phrase generated silently is a backup nobody has. +// +// Empty until a seed exists. +extern vector vchHDMaster; // 32-byte master private key (IL) +extern vector vchHDChainCode; // 32 bytes (IR) +extern unsigned int nHDNext; // next child index to derive +inline bool HaveHDSeed() { return vchHDMaster.size() == 32 && vchHDChainCode.size() == 32; } + +// Install a seed derived from a mnemonic, replacing any existing one, and reset +// the derivation counter. Writes to wallet.dat. Returns false and leaves the +// wallet untouched if the phrase is not valid. +bool SetHDSeedFromMnemonic(const string& strMnemonic, string& strErrorRet); + +// Derive the child at nIndex and return it as a key. Used by the key pool and +// by restore, which needs to run ahead of the pool. +bool DeriveHDKey(unsigned int nIndex, CKey& keyRet, string& strErrorRet); + // Fill the pool back up to KEYPOOL_SIZE. Every key it creates is written to // wallet.dat before it is offered to anybody. void TopUpKeyPool(); @@ -156,6 +183,7 @@ void ReacceptWalletTransactions(); // a key this wallet holds. Returns how many were added or updated. Needed by // anything that puts a key into the wallet after the fact -- an import, a // restored backup, and later a recovery phrase. +bool CanScanWalletTransactions(string& strErrorRet); int ScanForWalletTransactions(CBlockIndex* pindexStart); // Returns how many wallet transactions the chain corrected. int RescanSpentFlags(); diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 2bcdb83..aa9c23d 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -323,6 +323,16 @@ int main(int argc, char* argv[]) // import having failed. if (nAdded > 0) { + string strScanError; + if (!CanScanWalletTransactions(strScanError)) + { + fprintf(stderr, "Cannot scan for imported coins: %s\n", + strScanError.c_str()); + fprintf(stderr, "The key import was written, but the wallet " + "balance was not proven against the chain.\n"); + DBFlush(true); + return 1; + } printf("Looking through the chain for coins belonging to the imported key(s)...\n"); int nFound = ScanForWalletTransactions(pindexGenesisBlock); printf("Found %d transaction(s). Start the node normally to see the balance.\n", nFound); @@ -339,6 +349,14 @@ int main(int argc, char* argv[]) // balance is wrong for this reason. if (arg(argc,argv,"/rescan") || arg(argc,argv,"-rescan")) { + string strScanError; + if (!CanScanWalletTransactions(strScanError)) + { + fprintf(stderr, "Cannot rescan wallet transactions: %s\n", + strScanError.c_str()); + DBFlush(true); + return 1; + } printf("Rescanning the chain for this wallet's transactions...\n"); int nFound = ScanForWalletTransactions(pindexGenesisBlock); printf("Rescan done: %d transaction(s) added or updated.\n", nFound); diff --git a/src/makefile.mingw b/src/makefile.mingw index 4f297d9..07ae034 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -42,7 +42,7 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ - obj/selftest.o + obj/selftest.o obj/bip32.o GUI_OBJS = obj/gui.o obj/main_gui.o obj/bitflash_res.o @@ -103,3 +103,4 @@ test_bip32.exe: test_bip32.cpp bip32.cpp bip32.h bip39_english.h tests: test_bip32.exe bitflash.exe ./test_bip32.exe ./bitflash.exe -selftest=wallet-keypool + ./bitflash.exe -selftest=wallet-hd diff --git a/src/selftest.cpp b/src/selftest.cpp index abe1d15..6792ae7 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -314,14 +314,161 @@ static void AttachTerminal() #endif } +static int RunWalletHDSelfTest() +{ + fflush(stdout); + printf("wallet-hd self-test\n"); + + std::string tmp; + std::string cwd; + if (!MakeTempDir(tmp)) + { + printf(" FAIL could not create a temporary data directory\n"); + return 1; + } + if (!GetCurrentDir(cwd) || !SetCurrentDir(tmp)) + { + printf(" FAIL could not move into the temporary data directory\n"); + RemoveTree(tmp); + return 1; + } + + int nFail = 0; + strSetDataDir = tmp; + printf(" temp datadir: %s\n", tmp.c_str()); + + // Test-vector phrases, so nothing here depends on randomness. + const std::string strPhraseA = + "abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon abandon about"; + const std::string strPhraseB = + "legal winner thank year wave sausage worth useful " + "legal winner thank yellow"; + + try + { + if (!LoadWallet()) + throw std::runtime_error("LoadWallet failed"); + + std::string strError; + nFail += Check(SetHDSeedFromMnemonic(strPhraseA, strError), + "a valid phrase installs a seed") ? 0 : 1; + nFail += Check(HaveHDSeed(), "the wallet reports having a seed") ? 0 : 1; + + std::vector vchBefore = vchHDMaster; + nFail += Check(!SetHDSeedFromMnemonic("not a mnemonic at all", strError), + "an invalid phrase is refused") ? 0 : 1; + nFail += Check(vchHDMaster == vchBefore, + "a refused phrase leaves the existing seed alone") ? 0 : 1; + + // The property the whole feature exists for: the same words give back + // the same keys, in the same order. + std::vector first; + for (unsigned int i = 0; i < 5; i++) + { + CKey key; + if (!DeriveHDKey(i, key, strError)) + throw std::runtime_error("derivation failed: " + strError); + first.push_back(HexStrLocal(key.GetPubKey())); + } + + SetHDSeedFromMnemonic(strPhraseA, strError); + std::vector again; + for (unsigned int i = 0; i < 5; i++) + { + CKey key; + if (!DeriveHDKey(i, key, strError)) + throw std::runtime_error("derivation failed: " + strError); + again.push_back(HexStrLocal(key.GetPubKey())); + } + nFail += Check(first == again, + "the same phrase derives the same keys in the same order") ? 0 : 1; + + SetHDSeedFromMnemonic(strPhraseB, strError); + std::vector other; + for (unsigned int i = 0; i < 5; i++) + { + CKey key; + if (!DeriveHDKey(i, key, strError)) + throw std::runtime_error("derivation failed: " + strError); + other.push_back(HexStrLocal(key.GetPubKey())); + } + nFail += Check(first != other, "a different phrase derives different keys") ? 0 : 1; + + std::set distinct(first.begin(), first.end()); + nFail += Check(distinct.size() == first.size(), + "consecutive indices give distinct keys") ? 0 : 1; + + // With a seed installed the pool must be derived from it, and the + // counter must move exactly once per key. + SetHDSeedFromMnemonic(strPhraseA, strError); + CRITICAL_BLOCK(cs_keyPool) + mapKeyPool.clear(); + unsigned int nNextBefore = nHDNext; + TopUpKeyPool(); + + int nPool = 0; + CRITICAL_BLOCK(cs_keyPool) + nPool = (int)mapKeyPool.size(); + nFail += Check(nPool == KEYPOOL_SIZE, "the derived pool fills") ? 0 : 1; + nFail += Check(nHDNext == nNextBefore + (unsigned int)KEYPOOL_SIZE, + "the derivation counter advances once per pooled key") ? 0 : 1; + + std::set derived; + for (unsigned int i = nNextBefore; i < nHDNext; i++) + { + CKey key; + if (!DeriveHDKey(i, key, strError)) + throw std::runtime_error("derivation failed: " + strError); + derived.insert(HexStrLocal(key.GetPubKey())); + } + + bool fStored = true; + bool fFromSeed = true; + CRITICAL_BLOCK(cs_keyPool) + { + for (map >::const_iterator mi = mapKeyPool.begin(); + mi != mapKeyPool.end(); ++mi) + { + if (!WalletHasPrivateKey(mi->second)) + fStored = false; + if (!derived.count(HexStrLocal(mi->second))) + fFromSeed = false; + } + } + 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; + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + DBFlush(true); + SetCurrentDir(cwd); + RemoveTree(tmp); + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + int RunSelfTest(const std::string& name) { AttachTerminal(); if (name == "wallet-keypool") return RunWalletKeyPoolSelfTest(); + if (name == "wallet-hd") + return RunWalletHDSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool\n"); + printf("Known self-tests: wallet-keypool, wallet-hd\n"); return 1; } -- cgit v1.2.3 From 7de7ef89d3365f8f9c2eb2b6ed0193c3033e221c Mon Sep 17 00:00:00 2001 From: Bitflash Date: Sat, 1 Aug 2026 17:57:07 -0300 Subject: wallet: create and restore a recovery phrase (#96) Adds -newphrase, -restorephrase, -restoredepth, -newaddress and -showderived, plus the window buttons that reach the same code. Restore derives forward in batches of 100 and rescans after each, stopping when a whole batch turns up nothing; the phrase is shown on the terminal only, never in debug.log. Proved end to end on a live wallet: paid a derived address, deleted wallet.dat, restored from the twelve words and recovered the coin. Coins on the pre-seed default address do not come back, and the command says so. --- src/Makefile | 4 +- src/gui.cpp | 258 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main_gui.cpp | 38 ++++++++ src/makefile.mingw | 4 +- src/selftest.cpp | 30 +------ src/util.h | 27 ++++++ src/walletcmd.cpp | 237 ++++++++++++++++++++++++++++++++++++++++++++++++ src/walletcmd.h | 43 +++++++++ 8 files changed, 610 insertions(+), 31 deletions(-) create mode 100644 src/walletcmd.cpp create mode 100644 src/walletcmd.h (limited to 'src/selftest.cpp') diff --git a/src/Makefile b/src/Makefile index bbdc9e2..3eaf2ba 100644 --- a/src/Makefile +++ b/src/Makefile @@ -42,7 +42,7 @@ LIBS = -L$(SECP_LIB) -lsecp256k1 \ HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ key.h bignum.h script.h db.h base58.h net.h nostr.h \ randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h \ - selftest.h compat.h + selftest.h walletcmd.h compat.h IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ obj/imgui_widgets.o obj/imgui_impl_glfw.o obj/imgui_impl_opengl3.o @@ -50,7 +50,7 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ - obj/selftest.o obj/bip32.o + obj/selftest.o obj/bip32.o obj/walletcmd.o GUI_OBJS = obj/gui.o obj/main_gui.o diff --git a/src/gui.cpp b/src/gui.cpp index 51b5d5f..e800244 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -12,6 +12,11 @@ #include #include #include +#include +#include +#include +#include "bip32.h" +#include "walletcmd.h" #include "font_roboto.h" // --------------------------------------------------------------------------- @@ -910,6 +915,220 @@ static void DrawOptionsDialog() // --------------------------------------------------------------------------- // Wallet safety dialog // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Recovery phrase +// +// The phrase is shown once, and the seed is installed only after the user says +// they have written it down. Generating it and storing it in the same breath +// would hand somebody a wallet whose only backup is twelve words they may have +// closed the window on -- which is worse than no phrase at all, because they +// would believe they had one. +// +// Nothing here writes the words to disk or to debug.log. They live in one +// std::string that is cleared the moment the dialog closes. +// --------------------------------------------------------------------------- +static bool g_showCreatePhrase = false; +static bool g_showRestorePhrase = false; +static std::string g_pendingMnemonic; +static bool g_phraseWrittenDown = false; +static std::string g_phraseStatus; + +static char g_restoreInput[600] = {}; +static std::string g_restoreStatus; +static std::atomic g_restoreRunning(false); +static std::atomic g_restoreDerived(0); +static std::atomic g_restoreRecovered(0); +static std::string g_restoreResult; +static std::mutex g_restoreResultMutex; + +static void RestoreProgress(void*, int nDerived, int nRecovered) +{ + g_restoreDerived.store(nDerived); + g_restoreRecovered.store(nRecovered); +} + +static void RestoreThread(std::string strPhrase) +{ + std::string strError; + int nRecovered = 0, nDerived = 0; + bool fOk = RestoreFromPhrase(strPhrase, 0, RestoreProgress, NULL, + strError, nRecovered, nDerived); + { + std::lock_guard lock(g_restoreResultMutex); + if (!fOk) + g_restoreResult = "Nothing was changed. " + strError; + else if (nRecovered > 0) + g_restoreResult = strprintf( + "Restored %d transaction(s) across %d derived addresses. " + "Restart the node to see the balance.", nRecovered, nDerived); + else + g_restoreResult = strprintf( + "No transactions found for the first %d addresses of that phrase. " + "Check the words and their order.", nDerived); + } + g_restoreRunning.store(false); + g_needRefresh = true; +} + +static void DrawCreatePhraseDialog() +{ + if (!g_showCreatePhrase) return; + + ImGui::SetNextWindowSize(ImVec2(640.0f, 340.0f), ImGuiCond_Always); + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), + ImGuiCond_Always, ImVec2(0.5f, 0.5f)); + if (ImGui::Begin("Recovery Phrase", &g_showCreatePhrase, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse)) + { + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.25f, 1.0f), + "Write these twelve words down, in order, on paper."); + ImGui::TextWrapped( + "They are the only thing that can rebuild this wallet if the file is lost. " + "Anyone who reads them can spend your coins. They are not stored anywhere, " + "and this is the only time they will be shown."); + ImGui::Spacing(); + + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.10f, 0.10f, 0.10f, 1.0f)); + ImGui::BeginChild("##phrase", ImVec2(0.0f, 70.0f), true); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextColored(ImVec4(0.55f, 1.0f, 0.6f, 1.0f), "%s", g_pendingMnemonic.c_str()); + ImGui::PopTextWrapPos(); + ImGui::EndChild(); + ImGui::PopStyleColor(); + + ImGui::Spacing(); + ImGui::TextWrapped( + "Keys already in this wallet are NOT covered by the phrase -- they existed " + "before it did. Keep your file backups as well."); + ImGui::Spacing(); + + ImGui::Checkbox("I have written these words down", &g_phraseWrittenDown); + ImGui::Spacing(); + + if (!g_phraseWrittenDown) + ImGui::BeginDisabled(); + if (ImGui::Button("Finish", ImVec2(120.0f, 0.0f))) + { + std::string strError; + if (SetHDSeedFromMnemonic(g_pendingMnemonic, strError)) + { + TopUpKeyPool(); + g_phraseStatus = "Recovery phrase created. New addresses come from it."; + } + else + { + g_phraseStatus = "Could not install the seed: " + strError; + } + g_pendingMnemonic.clear(); + g_phraseWrittenDown = false; + g_showCreatePhrase = false; + g_needRefresh = true; + } + if (!g_phraseWrittenDown) + ImGui::EndDisabled(); + + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(120.0f, 0.0f))) + { + // Nothing was installed, so there is nothing to undo. The words go + // away with the string. + g_pendingMnemonic.clear(); + g_phraseWrittenDown = false; + g_showCreatePhrase = false; + g_phraseStatus = "Cancelled. No recovery phrase was created."; + } + } + ImGui::End(); + + // Closing with the window X is the same as cancelling. + if (!g_showCreatePhrase && !g_pendingMnemonic.empty()) + { + g_pendingMnemonic.clear(); + g_phraseWrittenDown = false; + g_phraseStatus = "Cancelled. No recovery phrase was created."; + } +} + +static void DrawRestorePhraseDialog() +{ + if (!g_showRestorePhrase) return; + + ImGui::SetNextWindowSize(ImVec2(640.0f, 330.0f), ImGuiCond_Always); + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), + ImGuiCond_Always, ImVec2(0.5f, 0.5f)); + if (ImGui::Begin("Restore From Phrase", &g_showRestorePhrase, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse)) + { + ImGui::TextWrapped( + "Type the twelve words, in order, separated by spaces. The node will " + "derive the addresses they describe and search the chain for their coins."); + ImGui::Spacing(); + + if (HaveHDSeed()) + { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.75f, 0.25f, 1.0f)); + ImGui::TextWrapped( + "This wallet already has a recovery phrase. Restoring replaces it: " + "coins on addresses from the current phrase would no longer come back " + "from the words you wrote down for it. Back up wallet.dat first."); + ImGui::PopStyleColor(); + ImGui::Spacing(); + } + + ImGui::SetNextItemWidth(-1.0f); + ImGui::InputTextMultiline("##phrase-in", g_restoreInput, sizeof(g_restoreInput), + ImVec2(0.0f, 60.0f)); + + ImGui::Spacing(); + bool fBusy = g_restoreRunning.load(); + if (fBusy) + ImGui::BeginDisabled(); + if (ImGui::Button("Restore", ImVec2(140.0f, 0.0f))) + { + std::string strWhy; + if (!CanScanWalletTransactions(strWhy)) + { + std::lock_guard lock(g_restoreResultMutex); + g_restoreResult = strWhy; + } + else + { + { + std::lock_guard lock(g_restoreResultMutex); + g_restoreResult.clear(); + } + g_restoreDerived.store(0); + g_restoreRecovered.store(0); + g_restoreRunning.store(true); + // On its own thread: a restore walks the whole chain once per + // batch and would otherwise freeze the window for as long as it + // takes. + std::thread(RestoreThread, std::string(g_restoreInput)).detach(); + } + } + if (fBusy) + ImGui::EndDisabled(); + + ImGui::SameLine(); + if (ImGui::Button("Close", ImVec2(100.0f, 0.0f)) && !fBusy) + g_showRestorePhrase = false; + + ImGui::Spacing(); + if (fBusy) + { + ImGui::Text("Searching: %d addresses checked, %d transaction(s) so far", + g_restoreDerived.load(), g_restoreRecovered.load()); + } + else + { + std::lock_guard lock(g_restoreResultMutex); + if (!g_restoreResult.empty()) + ImGui::TextWrapped("%s", g_restoreResult.c_str()); + } + } + ImGui::End(); +} + static void DrawWalletSafetyDialog() { if (!g_showWalletSafety) return; @@ -993,6 +1212,43 @@ static void DrawWalletSafetyDialog() ImGui::PopStyleColor(); } + ImGui::Spacing(); + ImGui::SeparatorText("Recovery Phrase"); + if (HaveHDSeed()) + ImGui::TextColored(ImVec4(0.55f, 1.0f, 0.6f, 1.0f), + "This wallet has a recovery phrase."); + else + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.25f, 1.0f), + "No recovery phrase. Only a file backup can rebuild this wallet."); + + if (!HaveHDSeed()) + { + if (ImGui::Button("Create recovery phrase", ImVec2(200.0f, 0.0f))) + { + std::string strError; + if (bitflash::BIP39GenerateMnemonic(g_pendingMnemonic, strError)) + { + g_phraseWrittenDown = false; + g_phraseStatus.clear(); + g_showCreatePhrase = true; + } + else + { + g_phraseStatus = "Could not generate a phrase: " + strError; + } + } + ImGui::SameLine(); + } + if (ImGui::Button("Restore from phrase", ImVec2(190.0f, 0.0f))) + g_showRestorePhrase = true; + + if (!g_phraseStatus.empty()) + ImGui::TextWrapped("%s", g_phraseStatus.c_str()); + + ImGui::TextWrapped( + "A phrase covers addresses created after it. Keys that already existed " + "are not derived from it, so keep the file backups above as well."); + ImGui::Spacing(); ImGui::SeparatorText("Key Pool"); int nPool = KeyPoolCount(); @@ -1157,6 +1413,8 @@ int RunGUI(int argc, char* argv[]) DrawSendDialog(); DrawOptionsDialog(); DrawWalletSafetyDialog(); + DrawCreatePhraseDialog(); + DrawRestorePhraseDialog(); DrawDiagnosticsDialog(); DrawAboutDialog(); DrawStatusBar(); diff --git a/src/main_gui.cpp b/src/main_gui.cpp index aa9c23d..c9e7dee 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -2,6 +2,7 @@ #include "headers_core.h" #include "selftest.h" +#include "walletcmd.h" #include // hardware_concurrency, to sanity-check /genproclimit #ifndef _WIN32 #include @@ -78,6 +79,11 @@ static void PrintUsage() printf(" /dumpwallet=FILE (export private keys as text -- readable by\n"); printf(" anyone, so guard it like cash)\n"); printf(" /importwallet=FILE (load keys from such a file back in)\n"); + printf(" /newaddress (print the next receiving address, then exit)\n"); + printf(" /newphrase (create a twelve-word recovery phrase for a\n"); + printf(" wallet that has none, show it once, exit)\n"); + printf(" /restorephrase=\"WORDS\" (rebuild this wallet from a phrase and scan\n"); + printf(" the chain for its coins, then exit)\n"); printf(" /rescan (walk the chain for coins this wallet owns\n"); printf(" but never recorded, then exit)\n"); printf("\n"); @@ -347,6 +353,38 @@ int main(int argc, char* argv[]) // /rescan -- walk the chain and pick up anything the wallet's keys own but // the wallet never recorded. Cheap to offer and the only recourse when a // balance is wrong for this reason. + string strShow = argval2(argc, argv, "/showderived", "-showderived"); + if (!strShow.empty()) + { + int nRet = CmdShowDerived(atoi(strShow.c_str())); + DBFlush(true); + return nRet; + } + + if (arg(argc,argv,"/newaddress") || arg(argc,argv,"-newaddress")) + { + int nRet = CmdNewAddress(); + DBFlush(true); + return nRet; + } + + if (arg(argc,argv,"/newphrase") || arg(argc,argv,"-newphrase")) + { + int nRet = CmdNewPhrase(); + DBFlush(true); + return nRet; + } + + string strRestore = argval2(argc, argv, "/restorephrase", "-restorephrase"); + if (!strRestore.empty()) + { + string strDepth = argval2(argc, argv, "/restoredepth", "-restoredepth"); + int nMinDepth = strDepth.empty() ? 0 : atoi(strDepth.c_str()); + int nRet = CmdRestorePhrase(strRestore, nMinDepth); + DBFlush(true); + return nRet; + } + if (arg(argc,argv,"/rescan") || arg(argc,argv,"-rescan")) { string strScanError; diff --git a/src/makefile.mingw b/src/makefile.mingw index 07ae034..94d2146 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -42,14 +42,14 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ - obj/selftest.o obj/bip32.o + obj/selftest.o obj/bip32.o obj/walletcmd.o GUI_OBJS = obj/gui.o obj/main_gui.o obj/bitflash_res.o HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ key.h bignum.h script.h db.h base58.h net.h nostr.h \ randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h \ - selftest.h + selftest.h walletcmd.h all: bitflash.exe diff --git a/src/selftest.cpp b/src/selftest.cpp index 6792ae7..b733c60 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -286,33 +286,9 @@ static int RunWalletKeyPoolSelfTest() return nFail == 0 ? 0 : 1; } -// A -mwindows binary starts with no console attached, so stdout goes nowhere -// even when it was launched from a terminal. Borrow the caller's console for -// the duration of the self-test. Nothing to do on Linux, where stdout is -// already connected. -static void AttachTerminal() -{ -#ifdef _WIN32 - // Only when there is nowhere for stdout to go. If the caller redirected it - // to a file or a pipe -- which is what `make tests > log` and any CI does -- - // that handle is inherited and already works, and reopening it on CONOUT$ - // would take the output away from the file and put it on the screen. That - // was the first version of this fix, and it broke the one case that - // mattered. - HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE); - if (hOut != NULL && hOut != INVALID_HANDLE_VALUE) - return; - - if (AttachConsole(ATTACH_PARENT_PROCESS)) - { - // Return values ignored on purpose: if the reopen fails there is - // nowhere left to report it, and the exit status still carries the - // result. - freopen("CONOUT$", "w", stdout); - freopen("CONOUT$", "w", stderr); - } -#endif -} +// AttachTerminal() moved to util.h: the wallet phrase commands need it too, +// and two copies of a console-attach that must get its one condition right is +// one copy too many. static int RunWalletHDSelfTest() { diff --git a/src/util.h b/src/util.h index 25ef118..d95d3ae 100644 --- a/src/util.h +++ b/src/util.h @@ -293,6 +293,33 @@ void PrintHex(const T pbegin, const T pend, const char* pszFormat="%s", bool fSp // Forward declaration -- defined in main.cpp string GetAppDir(); +// Give a command-line operation somewhere to print. +// +// The Windows binary is linked -mwindows and starts with no console, so stdout +// goes nowhere even when it was launched from a terminal. Commands that report +// to the person who typed them -- the self-tests, the wallet phrase commands -- +// call this first. +// +// Only when there is nowhere for stdout to go already: if the caller redirected +// it to a file or a pipe, that handle is inherited and works, and reopening it +// on CONOUT$ would take the output away from the file and put it on the screen. +inline void AttachTerminal() +{ +#ifdef _WIN32 + HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE); + if (hOut != NULL && hOut != INVALID_HANDLE_VALUE) + return; + if (AttachConsole(ATTACH_PARENT_PROCESS)) + { + // Return values ignored on purpose: if the reopen fails there is + // nowhere left to report it, and the exit status still carries the + // result. + freopen("CONOUT$", "w", stdout); + freopen("CONOUT$", "w", stderr); + } +#endif +} + inline int OutputDebugStringF(const char* pszFormat, ...) BF_FORMAT(1, 2); inline int OutputDebugStringF(const char* pszFormat, ...) diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp new file mode 100644 index 0000000..94e9d72 --- /dev/null +++ b/src/walletcmd.cpp @@ -0,0 +1,237 @@ +// Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. +// +// Recovery-phrase commands, from the command line. +// +// The phrase reaches the person who typed the command and nowhere else. In this +// tree printf is OutputDebugStringF, which writes into the data directory's +// debug.log -- the file we routinely ask users to attach to issues. A wallet's +// twelve words must never go there, so this file takes the real printf and +// AttachTerminal() gives it somewhere to land on Windows. + +#include "headers_core.h" +#include "bip32.h" +#include "walletcmd.h" + +#undef printf + +// How far ahead to look for used addresses when restoring. +// +// A wallet is restored from a phrase alone, so the node has no idea how many +// addresses the old one handed out. It derives forward in batches and stops +// once a whole batch turns up unused. A fixed number would be simpler and wrong +// for exactly this network: a miner burns an address per block, so a node that +// mined for a week is thousands of keys deep, and stopping at a few hundred +// would restore a wallet that looks empty. +static const int RESTORE_BATCH = 100; +static const int RESTORE_MAX = 10000; + +int CmdNewPhrase() +{ + AttachTerminal(); + + if (HaveHDSeed()) + { + printf("This wallet already has a recovery phrase.\n"); + printf("Creating another would replace it, and coins on addresses derived from\n"); + printf("the current one would no longer come back from the phrase you write down.\n"); + printf("Nothing was changed.\n"); + return 1; + } + + std::string strMnemonic, strError; + if (!bitflash::BIP39GenerateMnemonic(strMnemonic, strError)) + { + fprintf(stderr, "Could not generate a phrase: %s\n", strError.c_str()); + return 1; + } + + if (!SetHDSeedFromMnemonic(strMnemonic, strError)) + { + fprintf(stderr, "Could not install the seed: %s\n", strError.c_str()); + return 1; + } + + // Fill the pool from the new seed straight away, so the addresses this + // wallet hands out next are ones the phrase can reproduce. + TopUpKeyPool(); + + printf("\n"); + printf("Write these twelve words down, in order, on paper.\n"); + printf("They are the only thing that can rebuild this wallet if the file is lost.\n"); + printf("Anyone who reads them can spend your coins. They are not stored anywhere,\n"); + printf("and this is the only time they will be shown.\n"); + printf("\n"); + printf(" %s\n", strMnemonic.c_str()); + printf("\n"); + printf("Keys already in this wallet are NOT covered by the phrase -- they existed\n"); + printf("before it did. Keep your file backups as well.\n"); + fflush(stdout); + return 0; +} + +bool RestoreFromPhrase(const std::string& strMnemonic, + int nMinDepth, + RestoreProgressFn fnProgress, + void* pArg, + std::string& strErrorRet, + int& nRecoveredRet, + int& nDerivedRet) +{ + strErrorRet.clear(); + nRecoveredRet = 0; + nDerivedRet = 0; + + // Refuse before touching the wallet, not after. + // + // Without this, a restore on a node that has not synced derives the right + // keys, scans a chain that is not there, and reports "no transactions were + // found" -- which reads as "your coins are gone" to the one person least + // able to argue with it. Measured while testing: a copied data directory + // whose Berkeley DB environment failed to load left the node at height 0, + // the scan walked one block, and the message said nothing was found across + // 600 addresses. + if (!CanScanWalletTransactions(strErrorRet)) + return false; + + if (!SetHDSeedFromMnemonic(strMnemonic, strErrorRet)) + return false; + + // Derive forward in batches, scanning after each, until a whole batch turns + // up nothing. Every derived key is written to the wallet before the scan, + // because the scan asks the wallet what belongs to it. + int nTotalDerived = 0; + while (nTotalDerived < RESTORE_MAX) + { + // Count wallet transactions, not scan hits. + // + // ScanForWalletTransactions reports what it added *or updated*, and it + // walks the whole chain every time, so a transaction found in the first + // batch is reported again by every batch after it. Testing this against + // a real payment produced exactly +1 per batch and a loop that ran to + // the ceiling, deriving ten thousand addresses to rediscover one coin. + size_t nWalletBefore = 0; + CRITICAL_BLOCK(cs_mapWallet) + nWalletBefore = mapWallet.size(); + + std::string strDeriveError; + for (int i = 0; i < RESTORE_BATCH; i++) + { + CKey key; + if (!DeriveHDKey(nHDNext, key, strDeriveError)) + break; + if (!AddKey(key)) + break; + nHDNext++; + nTotalDerived++; + } + CWalletDB().WriteHDNext(nHDNext); + + ScanForWalletTransactions(pindexGenesisBlock); + + size_t nWalletAfter = 0; + CRITICAL_BLOCK(cs_mapWallet) + nWalletAfter = mapWallet.size(); + + if (fnProgress) + fnProgress(pArg, nTotalDerived, (int)nWalletAfter); + + // A whole batch with nothing in it means far enough -- unless the + // caller asked to look deeper anyway. + if (nWalletAfter == nWalletBefore && nTotalDerived >= nMinDepth) + break; + } + + // The pool is rebuilt from where derivation stopped, so the next address + // this wallet hands out is one the phrase can reproduce as well. + CRITICAL_BLOCK(cs_keyPool) + mapKeyPool.clear(); + TopUpKeyPool(); + + CRITICAL_BLOCK(cs_mapWallet) + nRecoveredRet = (int)mapWallet.size(); + nDerivedRet = nTotalDerived; + return true; +} + +static void PrintRestoreProgress(void*, int nDerived, int nRecovered) +{ + printf(" %d addresses checked, %d transaction(s) recovered\n", nDerived, nRecovered); + fflush(stdout); +} + +int CmdRestorePhrase(const std::string& strMnemonic, int nMinDepth) +{ + AttachTerminal(); + + printf("Checking the phrase and the chain.\n"); + fflush(stdout); + + std::string strError; + int nRecovered = 0, nDerived = 0; + if (!RestoreFromPhrase(strMnemonic, nMinDepth, PrintRestoreProgress, NULL, + strError, nRecovered, nDerived)) + { + fprintf(stderr, "Cannot restore: %s\n", strError.c_str()); + fprintf(stderr, "Nothing was changed.\n"); + return 1; + } + + printf("\n"); + if (nRecovered > 0) + printf("Restored: %d transaction(s) across %d derived addresses.\n", + nRecovered, nDerived); + else + printf("No transactions were found for the first %d addresses of that phrase.\n" + "If you expected coins, check the words and the order.\n", nDerived); + printf("Start the node normally to see the balance.\n"); + fflush(stdout); + return 0; +} + +int CmdNewAddress() +{ + AttachTerminal(); + + std::vector vchPubKey = GetKeyFromPool(); + if (vchPubKey.empty()) + { + fprintf(stderr, "Could not get a key from the wallet.\n"); + return 1; + } + + printf("%s\n", PubKeyToAddress(vchPubKey).c_str()); + if (!HaveHDSeed()) + printf("(this wallet has no recovery phrase, so only a file backup covers this address)\n"); + fflush(stdout); + return 0; +} + +// Diagnostic: print the addresses a phrase produces, so "the restore found +// nothing" can be told apart from "that address was never derived". +int CmdShowDerived(int nCount) +{ + AttachTerminal(); + + if (!HaveHDSeed()) + { + fprintf(stderr, "This wallet has no recovery phrase installed.\n"); + return 1; + } + if (nCount <= 0) + nCount = 20; + + std::string strError; + for (int i = 0; i < nCount; i++) + { + CKey key; + if (!DeriveHDKey((unsigned int)i, key, strError)) + { + fprintf(stderr, "derivation failed at %d: %s\n", i, strError.c_str()); + return 1; + } + printf("%6d %s\n", i, PubKeyToAddress(key.GetPubKey()).c_str()); + } + fflush(stdout); + return 0; +} diff --git a/src/walletcmd.h b/src/walletcmd.h new file mode 100644 index 0000000..89834d8 --- /dev/null +++ b/src/walletcmd.h @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. +// +// Recovery-phrase commands. Both run against a loaded wallet and then exit; +// neither starts the node. + +#ifndef BITFLASH_WALLETCMD_H +#define BITFLASH_WALLETCMD_H + +#include + +// Create a phrase for a wallet that does not have one, install the seed, and +// show the words once. Refuses if a phrase already exists. Returns 0 on success. +int CmdNewPhrase(); + +// Install the seed a phrase describes, derive forward looking for used +// addresses, and rescan the chain so their coins appear. Returns 0 on success. +int CmdRestorePhrase(const std::string& strMnemonic, int nMinDepth = 0); + +// Called once per batch with how many addresses have been derived so far and +// how many transactions the wallet now holds. +typedef void (*RestoreProgressFn)(void* pArg, int nDerived, int nRecovered); + +// The restore itself, shared by the command and the GUI so the two cannot +// drift apart. Returns false without touching the wallet if the chain is not +// loaded or the phrase is not valid. +bool RestoreFromPhrase(const std::string& strMnemonic, + int nMinDepth, + RestoreProgressFn fnProgress, + void* pArg, + std::string& strErrorRet, + int& nRecoveredRet, + int& nDerivedRet); + +// Take the next address from the key pool and print it. With a recovery phrase +// installed the address is derived, so the phrase can bring back whatever is +// paid to it. +int CmdNewAddress(); + +// Diagnostic: print the first nCount addresses this wallet's phrase derives. +int CmdShowDerived(int nCount); + +#endif -- cgit v1.2.3 From 5a5478669c487963e99491112abc065c59973966 Mon Sep 17 00:00:00 2001 From: mtg0001 <106211915+mtg0001@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:43:45 -0300 Subject: Keep new phrase addresses recoverable (#102) -newphrase installed a seed but left the wallet's visible address as the pre-seed random key and the old random pool intact, so the next address handed out -- a mining payout included -- could be one the twelve words cannot reproduce, while the window said a recovery phrase existed. Installing a phrase now clears the stale pool records (private keys stay in wallet.dat), reserves a derived default receiving key, tolerates already-stored derived keys so repeated installs do not stall the pool, and -restorephrase reports what it recovered rather than the wallet's total transaction count. Self-test covers installing a phrase over a pre-existing random pool. --- src/main.cpp | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++---- src/main.h | 5 +++-- src/selftest.cpp | 27 ++++++++++++++++++++++-- src/walletcmd.cpp | 17 +++++++++++++--- 4 files changed, 99 insertions(+), 11 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/main.cpp b/src/main.cpp index e5754e6..2c4e346 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -167,6 +167,20 @@ bool AddKey(const CKey& key) return CWalletDB().WriteKey(key.GetPubKey(), key.GetPrivKey()); } +static bool WalletAlreadyHasKey(const vector& vchPubKey) +{ + CRITICAL_BLOCK(cs_mapKeys) + return mapKeys.count(vchPubKey) > 0; + return false; +} + +static bool AddKeyIfMissing(const CKey& key) +{ + if (WalletAlreadyHasKey(key.GetPubKey())) + return true; + return AddKey(key); +} + vector GenerateNewKey() { CKey key; @@ -183,6 +197,23 @@ vector vchHDMaster; vector vchHDChainCode; unsigned int nHDNext = 0; +static bool ClearKeyPoolRecords(string& strErrorRet) +{ + CWalletDB walletdb; + for (map >::const_iterator mi = mapKeyPool.begin(); + mi != mapKeyPool.end(); ++mi) + { + if (!walletdb.ErasePool(mi->first)) + { + strErrorRet = strprintf("could not clear old key-pool entry %lld", + (long long)mi->first); + return false; + } + } + mapKeyPool.clear(); + return true; +} + bool DeriveHDKey(unsigned int nIndex, CKey& keyRet, string& strErrorRet) { if (!HaveHDSeed()) @@ -223,9 +254,12 @@ bool SetHDSeedFromMnemonic(const string& strMnemonic, string& strErrorRet) CRITICAL_BLOCK(cs_keyPool) { - // Written before the globals change, so a failed write leaves the - // wallet on the seed it already had rather than on one that exists - // only in memory. + // Old private keys stay in wallet.dat, but old unused pool records must + // not survive the seed. Otherwise the next address can still be random + // while the UI says a recovery phrase exists. + if (!ClearKeyPoolRecords(strErrorRet)) + return false; + if (!CWalletDB().WriteHDMaster(master.privateKey, master.chainCode) || !CWalletDB().WriteHDNext(0)) { @@ -235,6 +269,25 @@ bool SetHDSeedFromMnemonic(const string& strMnemonic, string& strErrorRet) vchHDMaster = master.privateKey; vchHDChainCode = master.chainCode; nHDNext = 0; + + CKey key; + if (!DeriveHDKey(0, key, strErrorRet)) + return false; + if (!AddKeyIfMissing(key)) + { + strErrorRet = "could not write the default derived key to wallet.dat"; + return false; + } + vector vchPubKey = key.GetPubKey(); + if (!CWalletDB().WriteDefaultKey(vchPubKey) || + !SetAddressBookName(PubKeyToAddress(vchPubKey), "Your Address") || + !CWalletDB().WriteHDNext(1)) + { + strErrorRet = "could not set the default derived receiving key"; + return false; + } + keyUser = key; + nHDNext = 1; } return true; } @@ -279,7 +332,7 @@ void TopUpKeyPool() // AddKey writes the private key. Order matters: the key is in // wallet.dat before anything can point at it, so a crash between // the two writes costs an unused key, never a usable one. - if (!AddKey(key)) + if (!AddKeyIfMissing(key)) return; vector vchPubKey = key.GetPubKey(); if (!CWalletDB().WritePool(nIndex, vchPubKey)) diff --git a/src/main.h b/src/main.h index a04a4e3..1b0be59 100644 --- a/src/main.h +++ b/src/main.h @@ -159,8 +159,9 @@ extern vector vchHDChainCode; // 32 bytes (IR) extern unsigned int nHDNext; // next child index to derive inline bool HaveHDSeed() { return vchHDMaster.size() == 32 && vchHDChainCode.size() == 32; } -// Install a seed derived from a mnemonic, replacing any existing one, and reset -// the derivation counter. Writes to wallet.dat. Returns false and leaves the +// Install a seed derived from a mnemonic, replacing any existing one. The +// default receiving key is derived immediately, so the next visible address is +// covered by the phrase. Writes to wallet.dat. Returns false and leaves the // wallet untouched if the phrase is not valid. bool SetHDSeedFromMnemonic(const string& strMnemonic, string& strErrorRet); diff --git a/src/selftest.cpp b/src/selftest.cpp index b733c60..bc9fa5a 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -327,10 +327,35 @@ static int RunWalletHDSelfTest() throw std::runtime_error("LoadWallet failed"); std::string strError; + TopUpKeyPool(); + int nRandomPoolSize = 0; + CRITICAL_BLOCK(cs_keyPool) + nRandomPoolSize = (int)mapKeyPool.size(); + nFail += Check(nRandomPoolSize == KEYPOOL_SIZE, + "a wallet can start with a random key pool") ? 0 : 1; + nFail += Check(SetHDSeedFromMnemonic(strPhraseA, strError), "a valid phrase installs a seed") ? 0 : 1; nFail += Check(HaveHDSeed(), "the wallet reports having a seed") ? 0 : 1; + int nPoolAfterSeed = 0; + CRITICAL_BLOCK(cs_keyPool) + nPoolAfterSeed = (int)mapKeyPool.size(); + nFail += Check(nPoolAfterSeed == 0, + "installing a phrase clears the old random key pool") ? 0 : 1; + nFail += Check(nHDNext == 1, + "installing a phrase reserves one derived default key") ? 0 : 1; + + CKey keyFirstDerived; + if (!DeriveHDKey(0, keyFirstDerived, strError)) + throw std::runtime_error("default derivation failed: " + strError); + std::vector vchDefaultKey; + bool fDefaultRead = CWalletDB("r").ReadDefaultKey(vchDefaultKey); + nFail += Check(fDefaultRead && vchDefaultKey == keyFirstDerived.GetPubKey(), + "the default receiving key is derived from the phrase") ? 0 : 1; + nFail += Check(WalletHasPrivateKey(vchDefaultKey), + "the derived default key is stored in wallet.dat") ? 0 : 1; + std::vector vchBefore = vchHDMaster; nFail += Check(!SetHDSeedFromMnemonic("not a mnemonic at all", strError), "an invalid phrase is refused") ? 0 : 1; @@ -378,8 +403,6 @@ static int RunWalletHDSelfTest() // With a seed installed the pool must be derived from it, and the // counter must move exactly once per key. SetHDSeedFromMnemonic(strPhraseA, strError); - CRITICAL_BLOCK(cs_keyPool) - mapKeyPool.clear(); unsigned int nNextBefore = nHDNext; TopUpKeyPool(); diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index 94e9d72..8eb3592 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -94,13 +94,17 @@ bool RestoreFromPhrase(const std::string& strMnemonic, if (!CanScanWalletTransactions(strErrorRet)) return false; + size_t nWalletStart = 0; + CRITICAL_BLOCK(cs_mapWallet) + nWalletStart = mapWallet.size(); + if (!SetHDSeedFromMnemonic(strMnemonic, strErrorRet)) return false; // Derive forward in batches, scanning after each, until a whole batch turns // up nothing. Every derived key is written to the wallet before the scan, // because the scan asks the wallet what belongs to it. - int nTotalDerived = 0; + int nTotalDerived = (int)nHDNext; while (nTotalDerived < RESTORE_MAX) { // Count wallet transactions, not scan hits. @@ -134,7 +138,11 @@ bool RestoreFromPhrase(const std::string& strMnemonic, nWalletAfter = mapWallet.size(); if (fnProgress) - fnProgress(pArg, nTotalDerived, (int)nWalletAfter); + { + size_t nRecoveredNow = nWalletAfter > nWalletStart ? + nWalletAfter - nWalletStart : 0; + fnProgress(pArg, nTotalDerived, (int)nRecoveredNow); + } // A whole batch with nothing in it means far enough -- unless the // caller asked to look deeper anyway. @@ -148,8 +156,11 @@ bool RestoreFromPhrase(const std::string& strMnemonic, mapKeyPool.clear(); TopUpKeyPool(); + size_t nWalletEnd = 0; CRITICAL_BLOCK(cs_mapWallet) - nRecoveredRet = (int)mapWallet.size(); + nWalletEnd = mapWallet.size(); + nRecoveredRet = (int)(nWalletEnd > nWalletStart ? + nWalletEnd - nWalletStart : 0); nDerivedRet = nTotalDerived; return true; } -- cgit v1.2.3 From d54a07170808b921c79e25c2a0a8f6b9c447d4ea Mon Sep 17 00:00:00 2001 From: Bitflash Date: Sat, 1 Aug 2026 21:48:03 -0300 Subject: wallet: reserve index 0 before deriving it, and name the address the phrase does not cover (#104) Follow-up to #102. Reserves derivation index 0 before deriving it, so a failure between installing the seed and writing the default key cannot leave the pool free to hand that index out as an ordinary key. Renames the address that stops being the default to 'Your Address (created before the recovery phrase)', which was otherwise left under the same name as the new one. Also documents why the key-pool clear precedes the seed write, correcting a review comment of mine that asked for the reverse. --- src/main.cpp | 42 ++++++++++++++++++++++++++++++++++-------- src/selftest.cpp | 12 ++++++++++++ 2 files changed, 46 insertions(+), 8 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/main.cpp b/src/main.cpp index 2c4e346..9082bd5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -254,21 +254,46 @@ bool SetHDSeedFromMnemonic(const string& strMnemonic, string& strErrorRet) CRITICAL_BLOCK(cs_keyPool) { - // Old private keys stay in wallet.dat, but old unused pool records must - // not survive the seed. Otherwise the next address can still be random - // while the UI says a recovery phrase exists. + // The pool is cleared before the seed is written, and the order is the + // safe one of the two. Old private keys stay in wallet.dat either way; + // what is at stake is which half-finished state a failure can leave. + // + // clear, then write -- a failed write leaves the old seed and an + // empty pool. TopUpKeyPool refills it, and nothing was promised to + // anybody. + // + // write, then clear -- a failed clear leaves a seed installed and the + // old random pool intact. The user has just been shown twelve words + // and the wallet goes on handing out addresses those words cannot + // reproduce. That is the bug this function exists to prevent. + // + // An earlier comment here said the seed was written first so a failure + // left the wallet untouched. That was true of the seed alone and stops + // being true once the pool has to move with it. if (!ClearKeyPoolRecords(strErrorRet)) return false; if (!CWalletDB().WriteHDMaster(master.privateKey, master.chainCode) || - !CWalletDB().WriteHDNext(0)) + !CWalletDB().WriteHDNext(1)) { strErrorRet = "could not write the seed to wallet.dat"; return false; } vchHDMaster = master.privateKey; vchHDChainCode = master.chainCode; - nHDNext = 0; + + // Index 0 is spoken for before it is derived, so a failure below cannot + // leave the pool free to hand it out as an ordinary key. The cost of + // the counter being ahead of the derivation is one unused index; the + // cost of the reverse is the default address and a pool key sharing a + // derivation path. + nHDNext = 1; + + // Remember which address stops being the default, so it can be named + // for what it is. Two entries reading "Your Address" -- one covered by + // the phrase and one not -- is the wrong thing to find at the moment a + // person has just written twelve words down. + vector vchOldDefault = keyUser.GetPubKey(); CKey key; if (!DeriveHDKey(0, key, strErrorRet)) @@ -280,14 +305,15 @@ bool SetHDSeedFromMnemonic(const string& strMnemonic, string& strErrorRet) } vector vchPubKey = key.GetPubKey(); if (!CWalletDB().WriteDefaultKey(vchPubKey) || - !SetAddressBookName(PubKeyToAddress(vchPubKey), "Your Address") || - !CWalletDB().WriteHDNext(1)) + !SetAddressBookName(PubKeyToAddress(vchPubKey), "Your Address")) { strErrorRet = "could not set the default derived receiving key"; return false; } + if (!vchOldDefault.empty() && vchOldDefault != vchPubKey) + SetAddressBookName(PubKeyToAddress(vchOldDefault), + "Your Address (created before the recovery phrase)"); keyUser = key; - nHDNext = 1; } return true; } diff --git a/src/selftest.cpp b/src/selftest.cpp index bc9fa5a..88e9863 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -334,6 +334,10 @@ static int RunWalletHDSelfTest() nFail += Check(nRandomPoolSize == KEYPOOL_SIZE, "a wallet can start with a random key pool") ? 0 : 1; + // 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::string strPreSeedAddr = PubKeyToAddress(keyUser.GetPubKey()); + nFail += Check(SetHDSeedFromMnemonic(strPhraseA, strError), "a valid phrase installs a seed") ? 0 : 1; nFail += Check(HaveHDSeed(), "the wallet reports having a seed") ? 0 : 1; @@ -356,6 +360,14 @@ static int RunWalletHDSelfTest() nFail += Check(WalletHasPrivateKey(vchDefaultKey), "the derived default key is stored in wallet.dat") ? 0 : 1; + std::string strNewAddr = PubKeyToAddress(vchDefaultKey); + nFail += Check(strPreSeedAddr != strNewAddr, + "the visible address stops being the pre-seed one") ? 0 : 1; + nFail += Check(mapAddressBook.count(strPreSeedAddr) > 0 && + mapAddressBook[strPreSeedAddr] != + mapAddressBook[strNewAddr], + "the pre-seed address is kept, named apart from the new one") ? 0 : 1; + std::vector vchBefore = vchHDMaster; nFail += Check(!SetHDSeedFromMnemonic("not a mnemonic at all", strError), "an invalid phrase is refused") ? 0 : 1; -- cgit v1.2.3 From 89f6d045c8e3dacbfbb369a3ac89c0c33de22b4c Mon Sep 17 00:00:00 2001 From: mtg0001 <106211915+mtg0001@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:56:50 -0300 Subject: Keep HD change recoverable and bound message stalls (#109) Change from an HD wallet now goes to a newly derived phrase-backed key instead of reusing a spent input's key. Reusing an input key silently moved value back to a key the phrase cannot reproduce; if derivation fails the transaction is refused rather than creating unrecoverable change. Legacy wallets with no seed keep the 0.1.0 behaviour. Restore also scans past the pre-generated key-pool window before treating an empty batch as the end. The message parser no longer consumes a header it cannot complete: it reads from a copy, so the O(n) reinsert and the 100 ms sleep inside the shared message thread are gone, and a peer that sits on an incomplete payload is disconnected after two minutes. The wire cap drops from 256 MB to MAX_SIZE. New net-message self-test covers complete, incomplete, stale and oversized headers. Verified here: clean rebuild, all self-tests pass, lock order checked (no path takes cs_keyPool before cs_mapWallet), and a node built from this branch synced from genesis to the chain tip on the live network with zero rejections. --- src/Makefile | 1 + src/main.cpp | 104 +++++++++++++++++++++++++++++++++++++++++++++-------- src/makefile.mingw | 1 + src/net.h | 15 +++++++- src/selftest.cpp | 65 ++++++++++++++++++++++++++++++++- src/walletcmd.cpp | 7 +++- 6 files changed, 176 insertions(+), 17 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/Makefile b/src/Makefile index 5ded7d6..88dc08f 100644 --- a/src/Makefile +++ b/src/Makefile @@ -139,3 +139,4 @@ tests: test_bip32 bitflash-node ./test_bip32 ./bitflash-node -selftest=wallet-keypool ./bitflash-node -selftest=wallet-hd + ./bitflash-node -selftest=net-message diff --git a/src/main.cpp b/src/main.cpp index 9082bd5..9bb3c24 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -181,6 +181,39 @@ static bool AddKeyIfMissing(const CKey& key) return AddKey(key); } +static bool DeriveNextHDWalletKey(vector& vchPubKeyRet, string& strErrorRet) +{ + vchPubKeyRet.clear(); + CRITICAL_BLOCK(cs_keyPool) + { + if (!HaveHDSeed()) + { + strErrorRet = "no seed"; + return false; + } + + CKey key; + if (!DeriveHDKey(nHDNext, key, strErrorRet)) + return false; + if (!AddKeyIfMissing(key)) + { + strErrorRet = "could not write the derived key to wallet.dat"; + return false; + } + + unsigned int nNext = nHDNext + 1; + if (!CWalletDB().WriteHDNext(nNext)) + { + strErrorRet = "could not advance the derivation counter"; + return false; + } + + nHDNext = nNext; + vchPubKeyRet = key.GetPubKey(); + } + return true; +} + vector GenerateNewKey() { CKey key; @@ -2310,27 +2343,55 @@ bool ProcessMessages(CNode* pfrom) if (LogAcceptsCategory("net")) printf("\n\nPROCESSMESSAGE SKIPPED %d BYTES\n\n", (int)(pstart - vRecv.begin())); vRecv.erase(vRecv.begin(), pstart); - // Read header + // Read the header from a copy first. The old path consumed the header, + // noticed the payload was incomplete, inserted the header back at the + // front of the receive buffer, then slept inside the shared message + // thread. A peer could announce a large payload and drip bytes forever, + // making every pass pay for an O(n) insert and a 100 ms stall. CMessageHeader hdr; - vRecv >> hdr; + CDataStream vHeader(vRecv.begin(), vRecv.begin() + sizeof(CMessageHeader), + vRecv.nType, vRecv.nVersion); + vHeader >> hdr; if (!hdr.IsValid()) { if (LogAcceptsCategory("net")) printf("\n\nPROCESSMESSAGE: ERRORS IN HEADER %s\n\n\n", hdr.GetCommand().c_str()); + if (hdr.nMessageSize > MAX_PROTOCOL_MESSAGE_SIZE) + pfrom->fDisconnect = true; + pfrom->nIncompleteMessageStart = 0; + vRecv.ignore(sizeof(CMessageHeader)); continue; } string strCommand = hdr.GetCommand(); // Message size unsigned int nMessageSize = hdr.nMessageSize; - if (nMessageSize > vRecv.size()) + unsigned int nPayloadAvailable = vRecv.size() - sizeof(CMessageHeader); + if (nMessageSize > nPayloadAvailable) { - // Rewind and wait for rest of message - ///// need a mechanism to give up waiting for overlong message size error - if (LogAcceptsCategory("net")) printf("MESSAGE-BREAK 2\n"); - vRecv.insert(vRecv.begin(), BEGIN(hdr), END(hdr)); - Sleep(100); + if (pfrom->nIncompleteMessageStart == 0 || + pfrom->nIncompleteMessageSize != nMessageSize || + pfrom->strIncompleteMessageCommand != strCommand) + { + pfrom->nIncompleteMessageStart = GetTime(); + pfrom->nIncompleteMessageSize = nMessageSize; + pfrom->strIncompleteMessageCommand = strCommand; + } + else if (GetTime() - pfrom->nIncompleteMessageStart > + BTF_INCOMPLETE_MESSAGE_TIMEOUT_SECS) + { + LogPrint("net", + "disconnecting %s: incomplete %s message, %u of %u bytes after %d seconds\n", + pfrom->addr.ToString().c_str(), strCommand.c_str(), + nPayloadAvailable, nMessageSize, + BTF_INCOMPLETE_MESSAGE_TIMEOUT_SECS); + pfrom->fDisconnect = true; + } break; } + pfrom->nIncompleteMessageStart = 0; + pfrom->nIncompleteMessageSize = 0; + pfrom->strIncompleteMessageCommand.clear(); + vRecv.ignore(sizeof(CMessageHeader)); // Copy message to its own buffer CDataStream vMsg(vRecv.begin(), vRecv.begin() + nMessageSize, vRecv.nType, vRecv.nVersion); @@ -3774,13 +3835,28 @@ bool CreateTransaction(CScript scriptPubKey, int64 nValue, CWalletTx& wtxNew, in // Fill vout[1] back to self with any change if (nValueIn > nTotalValue) { - // Use the same key as one of the coins vector vchPubKey; - CTransaction& txFirst = *(*setCoins.begin()); - foreach(const CTxOut& txout, txFirst.vout) - if (txout.IsMine()) - if (ExtractPubKey(txout.scriptPubKey, true, vchPubKey)) - break; + if (HaveHDSeed()) + { + string strError; + if (!DeriveNextHDWalletKey(vchPubKey, strError)) + { + printf("CreateTransaction() : could not derive a phrase-backed change key: %s\n", + strError.c_str()); + return false; + } + } + else + { + // Legacy wallets have no deterministic seed. Preserve + // the 0.1.0 behaviour for them: return change to a key + // already proven to belong to this wallet. + CTransaction& txFirst = *(*setCoins.begin()); + foreach(const CTxOut& txout, txFirst.vout) + if (txout.IsMine()) + if (ExtractPubKey(txout.scriptPubKey, true, vchPubKey)) + break; + } if (vchPubKey.empty()) return false; diff --git a/src/makefile.mingw b/src/makefile.mingw index 5be3704..5025acb 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -111,3 +111,4 @@ tests: test_bip32.exe bitflash.exe ./test_bip32.exe ./bitflash.exe -selftest=wallet-keypool ./bitflash.exe -selftest=wallet-hd + ./bitflash.exe -selftest=net-message diff --git a/src/net.h b/src/net.h index 1c0c24e..37f335e 100644 --- a/src/net.h +++ b/src/net.h @@ -67,6 +67,13 @@ static const int BTF_RECV_TIMEOUT_SECS = 30 * 60; static const int BTF_SEND_STALL_SECS = 10 * 60; static const int BTF_HANDSHAKE_GRACE_SECS = 60; +// A serialized object cannot be larger than MAX_SIZE, so no valid network +// message needs the old 256 MB header allowance. Keep the wire cap aligned with +// what the deserializer can accept and bound how long one peer may sit on a +// partial payload after a complete header has arrived. +static const unsigned int MAX_PROTOCOL_MESSAGE_SIZE = MAX_SIZE; +static const int BTF_INCOMPLETE_MESSAGE_TIMEOUT_SECS = 2 * 60; + // Ceiling on simultaneous connections. // // ThreadSocketHandler watches every peer through one select(), and select() @@ -225,7 +232,7 @@ public: } // Message size - if (nMessageSize > 0x10000000) + if (nMessageSize > MAX_PROTOCOL_MESSAGE_SIZE) { if (LogAcceptsCategory("net")) printf("CMessageHeader::IsValid() : nMessageSize too large %u\n", nMessageSize); return false; @@ -596,6 +603,9 @@ public: int64 nLastSend; int64 nLastRecv; int64 nLastSendEmpty; + int64 nIncompleteMessageStart; + unsigned int nIncompleteMessageSize; + string strIncompleteMessageCommand; // Height this peer announced in its version message, or -1 if it sent a // version message that predates the field. Knowing how far along everyone @@ -626,6 +636,9 @@ public: nLastSend = 0; nLastRecv = 0; nLastSendEmpty = GetTime(); + nIncompleteMessageStart = 0; + nIncompleteMessageSize = 0; + strIncompleteMessageCommand.clear(); nStartingHeight = -1; vfSubscribe.assign(256, false); diff --git a/src/selftest.cpp b/src/selftest.cpp index 88e9863..c04adf4 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -470,6 +470,67 @@ static int RunWalletHDSelfTest() return nFail == 0 ? 0 : 1; } +static int RunNetMessageSelfTest() +{ + fflush(stdout); + printf("net-message self-test\n"); + + int nFail = 0; + try + { + CNode complete(INVALID_SOCKET, CAddress("127.0.0.1")); + complete.nVersion = VERSION; + complete.vRecv << CMessageHeader("ping", 0); + nFail += Check(ProcessMessages(&complete), "a complete empty message processes") ? 0 : 1; + nFail += Check(complete.vRecv.empty(), "a complete message is consumed") ? 0 : 1; + nFail += Check(!complete.fDisconnect, "a valid ping does not disconnect the peer") ? 0 : 1; + + CNode partial(INVALID_SOCKET, CAddress("127.0.0.1")); + partial.nVersion = VERSION; + partial.vRecv << CMessageHeader("block", 100); + unsigned int nPartialBefore = partial.vRecv.size(); + nFail += Check(ProcessMessages(&partial), "an incomplete message returns cleanly") ? 0 : 1; + nFail += Check(partial.vRecv.size() == nPartialBefore, + "an incomplete message keeps one header in the buffer") ? 0 : 1; + nFail += Check(partial.nIncompleteMessageStart != 0, + "an incomplete message starts a timeout clock") ? 0 : 1; + nFail += Check(!partial.fDisconnect, + "a fresh incomplete message does not disconnect immediately") ? 0 : 1; + + CNode stale(INVALID_SOCKET, CAddress("127.0.0.1")); + stale.nVersion = VERSION; + stale.vRecv << CMessageHeader("block", 100); + stale.nIncompleteMessageStart = GetTime() - BTF_INCOMPLETE_MESSAGE_TIMEOUT_SECS - 1; + stale.nIncompleteMessageSize = 100; + stale.strIncompleteMessageCommand = "block"; + ProcessMessages(&stale); + nFail += Check(stale.fDisconnect, + "a stale incomplete message disconnects the peer") ? 0 : 1; + + CNode oversized(INVALID_SOCKET, CAddress("127.0.0.1")); + oversized.nVersion = VERSION; + oversized.vRecv << CMessageHeader("block", MAX_PROTOCOL_MESSAGE_SIZE + 1); + ProcessMessages(&oversized); + nFail += Check(oversized.fDisconnect, + "an oversized message header disconnects the peer") ? 0 : 1; + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + int RunSelfTest(const std::string& name) { AttachTerminal(); @@ -478,8 +539,10 @@ int RunSelfTest(const std::string& name) return RunWalletKeyPoolSelfTest(); if (name == "wallet-hd") return RunWalletHDSelfTest(); + if (name == "net-message") + return RunNetMessageSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, net-message\n"); return 1; } diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index 8eb3592..fae2095 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -25,6 +25,10 @@ // would restore a wallet that looks empty. static const int RESTORE_BATCH = 100; static const int RESTORE_MAX = 10000; +// The wallet may have a full unused key pool in front of a change address +// created while spending pre-phrase coins. Looking only one empty batch ahead +// can stop just before that change output. +static const int RESTORE_MIN_SCAN = KEYPOOL_SIZE + RESTORE_BATCH; int CmdNewPhrase() { @@ -105,6 +109,7 @@ bool RestoreFromPhrase(const std::string& strMnemonic, // up nothing. Every derived key is written to the wallet before the scan, // because the scan asks the wallet what belongs to it. int nTotalDerived = (int)nHDNext; + int nStopDepth = max(nMinDepth, RESTORE_MIN_SCAN); while (nTotalDerived < RESTORE_MAX) { // Count wallet transactions, not scan hits. @@ -146,7 +151,7 @@ bool RestoreFromPhrase(const std::string& strMnemonic, // A whole batch with nothing in it means far enough -- unless the // caller asked to look deeper anyway. - if (nWalletAfter == nWalletBefore && nTotalDerived >= nMinDepth) + if (nWalletAfter == nWalletBefore && nTotalDerived >= nStopDepth) break; } -- cgit v1.2.3 From fe04a2045c3080797c6f93bf7cd2e12192b8f68f Mon Sep 17 00:00:00 2001 From: Bitflash Date: Sun, 2 Aug 2026 11:04:00 -0300 Subject: Add consensus block size limit (#111, from #110) CheckBlock() validated block size against MAX_SIZE, the serializer's generic 32 MB guard rather than a block rule; MAX_BLOCK_SIZE did not exist in this tree at all, because Bitcoin's 1 MB cap arrived in 0.3.x, after the 0.1.0 this fork came from. A miner could produce a 32 MB block and every node would download, verify and store it. Original work by mtg0001 in #110, rebased over #109 with the self-test conflict resolved, plus a comment explaining why the inherited count-against-bytes test stays. Measured before choosing the number: 5709 blocks read from blk0001.dat give a largest block of 34,290 bytes and a median of 216, so 1 MB leaves 29x of headroom and rejects nothing that exists. A node built from this branch then synced genesis to 5706 on the live network with zero size-limit rejections and zero CheckBlock errors -- the same test that caught BIP30 rejecting a real block at height 859. --- src/Makefile | 1 + src/main.cpp | 2 +- src/main.h | 21 ++++++++++++++++++ src/makefile.mingw | 1 + src/selftest.cpp | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 85 insertions(+), 2 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/Makefile b/src/Makefile index 88dc08f..82e7d06 100644 --- a/src/Makefile +++ b/src/Makefile @@ -140,3 +140,4 @@ tests: test_bip32 bitflash-node ./bitflash-node -selftest=wallet-keypool ./bitflash-node -selftest=wallet-hd ./bitflash-node -selftest=net-message + ./bitflash-node -selftest=consensus-limits diff --git a/src/main.cpp b/src/main.cpp index 9bb3c24..1b09a1a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1719,7 +1719,7 @@ bool CBlock::CheckBlock() const // that can be verified before saving an orphan block. // Size limits - if (vtx.empty() || vtx.size() > MAX_SIZE || ::GetSerializeSize(*this, SER_DISK) > MAX_SIZE) + if (!CheckSizeLimits()) return error("CheckBlock() : size limits failed"); // Check timestamp diff --git a/src/main.h b/src/main.h index 1b0be59..768f1c7 100644 --- a/src/main.h +++ b/src/main.h @@ -29,6 +29,10 @@ static const unsigned int MAX_ORPHAN_BLOCKS = 200; // so a block within the size limit could still hold millions of them and take // the network minutes of CPU to reject. Same value Bitcoin settled on. static const unsigned int MAX_BLOCK_SIGOPS = 20000; +// Consensus block-size ceiling. MAX_SIZE is the serializer's generic safety +// limit (32 MB), not a block rule. Bitcoin 0.1.0 predated the later 1 MB block +// cap, so this tree inherited no real block-size consensus limit. +static const unsigned int MAX_BLOCK_SIZE = 1000000; static const int64 COIN = 100000000; // Total Bitflash emission: identical to Bitcoin (21 million). // MAX_MONEY guards against the value overflow bug (CVE-2010-5139), which in @@ -1053,6 +1057,23 @@ public: return RandomXPoWHash((const void*)BEGIN(nVersion), END(nNonce) - BEGIN(nVersion)); } + bool CheckSizeLimits() const + { + // The middle test compares a transaction *count* against a *byte* + // limit. It is inherited from 0.1.0, where the same line compared + // vtx.size() against MAX_SIZE, and it is inert: the smallest possible + // transaction serializes to more than sixty bytes, so a block holding + // over a million of them cannot also weigh under a megabyte. + // + // Kept rather than removed. This is consensus code, and a rewrite that + // reads better is still a rewrite that can change what the network + // accepts. Written down so the next reader does not have to derive it, + // or "fix" it into something that does. + return (!vtx.empty() && + vtx.size() <= MAX_BLOCK_SIZE && + ::GetSerializeSize(*this, SER_DISK) <= MAX_BLOCK_SIZE); + } + uint256 BuildMerkleTree() const { diff --git a/src/makefile.mingw b/src/makefile.mingw index 5025acb..9bbfef1 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -112,3 +112,4 @@ tests: test_bip32.exe bitflash.exe ./bitflash.exe -selftest=wallet-keypool ./bitflash.exe -selftest=wallet-hd ./bitflash.exe -selftest=net-message + ./bitflash.exe -selftest=consensus-limits diff --git a/src/selftest.cpp b/src/selftest.cpp index c04adf4..67f96f8 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -531,6 +531,64 @@ static int RunNetMessageSelfTest() return nFail == 0 ? 0 : 1; } +static CBlock MakeSizedConsensusBlock(size_t nScriptBytes) +{ + CTransaction tx; + tx.vin.push_back(CTxIn(COutPoint(), CScript() << 1 << 1)); + tx.vout.push_back(CTxOut(0, CScript())); + tx.vout[0].scriptPubKey.insert(tx.vout[0].scriptPubKey.end(), nScriptBytes, 0); + + CBlock block; + block.vtx.push_back(tx); + block.hashMerkleRoot = block.BuildMerkleTree(); + block.nVersion = 1; + block.nTime = GetAdjustedTime(); + block.nBits = bnProofOfWorkLimit.GetCompact(); + block.nNonce = 0; + return block; +} + +static int RunConsensusLimitsSelfTest() +{ + fflush(stdout); + printf("consensus-limits self-test\n"); + + int nFail = 0; + try + { + nFail += Check(MAX_BLOCK_SIZE == 1000000, + "the consensus block-size cap is 1 MB") ? 0 : 1; + nFail += Check(MAX_BLOCK_SIZE < MAX_SIZE, + "the block cap is tighter than the serializer cap") ? 0 : 1; + + CBlock small = MakeSizedConsensusBlock(100); + nFail += Check(small.CheckSizeLimits(), + "a small block is within the consensus size limit") ? 0 : 1; + + CBlock oversized = MakeSizedConsensusBlock(MAX_BLOCK_SIZE); + unsigned int nSerialized = ::GetSerializeSize(oversized, SER_DISK); + nFail += Check(nSerialized > MAX_BLOCK_SIZE && nSerialized <= MAX_SIZE, + "the test block sits between 1 MB and the old 32 MB cap") ? 0 : 1; + nFail += Check(!oversized.CheckSizeLimits(), + "a block above 1 MB is outside the consensus size limit") ? 0 : 1; + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + int RunSelfTest(const std::string& name) { AttachTerminal(); @@ -541,8 +599,10 @@ int RunSelfTest(const std::string& name) return RunWalletHDSelfTest(); if (name == "net-message") return RunNetMessageSelfTest(); + if (name == "consensus-limits") + return RunConsensusLimitsSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, net-message\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, net-message, consensus-limits\n"); return 1; } -- cgit v1.2.3 From 57441faffb02f4f2eea37d31b29728de25f18bef Mon Sep 17 00:00:00 2001 From: mtg0001 <106211915+mtg0001@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:43:42 -0300 Subject: Add wallet recovery balance audit (#117) Co-authored-by: Gilcimar Duarte --- 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(-) (limited to 'src/selftest.cpp') diff --git a/src/gui.cpp b/src/gui.cpp index e3a8499..2335bf9 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; } @@ -1021,6 +1037,7 @@ static void DrawCreatePhraseDialog() if (SetHDSeedFromMnemonic(g_pendingMnemonic, strError)) { TopUpKeyPool(); + g_recoveryAuditTime = 0; g_phraseStatus = "Recovery phrase created. New addresses come from it."; } else @@ -1141,8 +1158,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, @@ -1229,6 +1247,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 -- cgit v1.2.3 From aba252206bf39ac81e6627354062c28fae05b0aa Mon Sep 17 00:00:00 2001 From: mtg0001 <106211915+mtg0001@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:11:55 -0300 Subject: Activate BIP44 wallet derivation (#124) * Add BIP32 path derivation foundation * Add HD wallet schema metadata * Add provisional BIP44 coin type metadata * Activate BIP44 wallet derivation * wallet: scan BIP44 restore depth per branch * wallet: widen default BIP44 restore lookahead --------- Co-authored-by: Gilcimar Duarte --- src/bip32.cpp | 109 +++++++++++++++++++++---- src/bip32.h | 14 +++- src/db.cpp | 50 ++++++++++++ src/db.h | 27 ++++++- src/gui.cpp | 5 ++ src/main.cpp | 232 +++++++++++++++++++++++++++++++++++++++++++++++++---- src/main.h | 55 ++++++++++--- src/selftest.cpp | 115 +++++++++++++++++++++++--- src/test_bip32.cpp | 119 +++++++++++++++++++++++++++ src/walletcmd.cpp | 117 ++++++++++++++++++++++----- src/walletcmd.h | 9 +++ 11 files changed, 775 insertions(+), 77 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/bip32.cpp b/src/bip32.cpp index 6423974..6ba946a 100644 --- a/src/bip32.cpp +++ b/src/bip32.cpp @@ -114,6 +114,43 @@ static bool HmacSha512(const unsigned char* key, int keyLen, len == 64; } +static bool PrivateKeyToCompressedPubKey(const std::vector& privateKey, + unsigned char out[33], + std::string& errorOut) +{ + if (!IsValidPrivateKey(privateKey)) + { + errorOut = "private key is invalid"; + return false; + } + + BIGNUM* bn = BN_bin2bn(&privateKey[0], 32, NULL); + BN_CTX* ctx = BN_CTX_new(); + EC_GROUP* group = EC_GROUP_new_by_curve_name(NID_secp256k1); + EC_POINT* pub = group ? EC_POINT_new(group) : NULL; + + bool ok = false; + if (bn && ctx && group && pub && + EC_POINT_mul(group, pub, bn, NULL, NULL, ctx)) + { + size_t n = EC_POINT_point2oct(group, pub, POINT_CONVERSION_COMPRESSED, + out, 33, ctx); + ok = n == 33; + } + + BN_free(bn); + BN_CTX_free(ctx); + EC_POINT_free(pub); + EC_GROUP_free(group); + + if (!ok) + { + errorOut = "could not serialize parent public key"; + return false; + } + return true; +} + bool BIP39EntropyToMnemonic(const std::vector& entropy, std::string& mnemonicOut, std::string& errorOut) @@ -284,10 +321,10 @@ bool BIP32MasterFromSeed(const std::vector& seed, return true; } -bool BIP32DeriveHardenedChild(const BIP32PrivateNode& parent, - unsigned int childIndex, - BIP32PrivateNode& childOut, - std::string& errorOut) +bool BIP32DeriveChild(const BIP32PrivateNode& parent, + unsigned int childNumber, + BIP32PrivateNode& childOut, + std::string& errorOut) { childOut.privateKey.clear(); childOut.chainCode.clear(); @@ -297,20 +334,21 @@ bool BIP32DeriveHardenedChild(const BIP32PrivateNode& parent, errorOut = "parent private key or chain code is invalid"; return false; } - if (childIndex >= 0x80000000U) + unsigned char data[37]; + if (childNumber & BIP32_HARDENED) { - errorOut = "child index must be non-hardened; hardening is applied here"; - return false; + data[0] = 0x00; + memcpy(data + 1, &parent.privateKey[0], 32); } - - const unsigned int hardened = childIndex | 0x80000000U; - unsigned char data[37]; - data[0] = 0x00; - memcpy(data + 1, &parent.privateKey[0], 32); - data[33] = (unsigned char)((hardened >> 24) & 0xff); - data[34] = (unsigned char)((hardened >> 16) & 0xff); - data[35] = (unsigned char)((hardened >> 8) & 0xff); - data[36] = (unsigned char)(hardened & 0xff); + else + { + if (!PrivateKeyToCompressedPubKey(parent.privateKey, data, errorOut)) + return false; + } + data[33] = (unsigned char)((childNumber >> 24) & 0xff); + data[34] = (unsigned char)((childNumber >> 16) & 0xff); + data[35] = (unsigned char)((childNumber >> 8) & 0xff); + data[36] = (unsigned char)(childNumber & 0xff); unsigned char I[64]; if (!HmacSha512(&parent.chainCode[0], 32, data, sizeof(data), I)) @@ -363,4 +401,43 @@ bool BIP32DeriveHardenedChild(const BIP32PrivateNode& parent, return true; } +bool BIP32DerivePath(const BIP32PrivateNode& root, + const std::vector& path, + BIP32PrivateNode& nodeOut, + std::string& errorOut) +{ + nodeOut.privateKey.clear(); + nodeOut.chainCode.clear(); + errorOut.clear(); + if (!IsValidPrivateKey(root.privateKey) || root.chainCode.size() != 32) + { + errorOut = "root private key or chain code is invalid"; + return false; + } + + BIP32PrivateNode cur = root; + for (size_t i = 0; i < path.size(); i++) + { + BIP32PrivateNode next; + if (!BIP32DeriveChild(cur, path[i], next, errorOut)) + return false; + cur = next; + } + nodeOut = cur; + return true; +} + +bool BIP32DeriveHardenedChild(const BIP32PrivateNode& parent, + unsigned int childIndex, + BIP32PrivateNode& childOut, + std::string& errorOut) +{ + if (childIndex >= BIP32_HARDENED) + { + errorOut = "child index must be non-hardened; hardening is applied here"; + return false; + } + return BIP32DeriveChild(parent, childIndex | BIP32_HARDENED, childOut, errorOut); +} + } // namespace bitflash diff --git a/src/bip32.h b/src/bip32.h index a029444..e9862d5 100644 --- a/src/bip32.h +++ b/src/bip32.h @@ -5,7 +5,7 @@ // // This module is deliberately independent from wallet.dat and GUI code. It is // the auditable foundation: mnemonic generation/validation, seed derivation, -// and hardened private child derivation over secp256k1. +// and private child derivation over secp256k1. #ifndef BITFLASH_BIP32_H #define BITFLASH_BIP32_H @@ -16,6 +16,8 @@ namespace bitflash { +static const unsigned int BIP32_HARDENED = 0x80000000U; + struct BIP32PrivateNode { std::vector privateKey; // 32-byte scalar @@ -43,6 +45,16 @@ bool BIP32MasterFromSeed(const std::vector& seed, BIP32PrivateNode& nodeOut, std::string& errorOut); +bool BIP32DeriveChild(const BIP32PrivateNode& parent, + unsigned int childNumber, + BIP32PrivateNode& childOut, + std::string& errorOut); + +bool BIP32DerivePath(const BIP32PrivateNode& root, + const std::vector& path, + BIP32PrivateNode& nodeOut, + std::string& errorOut); + bool BIP32DeriveHardenedChild(const BIP32PrivateNode& parent, unsigned int childIndex, BIP32PrivateNode& childOut, diff --git a/src/db.cpp b/src/db.cpp index 294920b..b7e7b91 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -554,6 +554,7 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) // Whether wallet.dat actually carried a mining mode, as opposed to just the // ancient fGenerateBitcoins flag. See the reconciliation further down. bool fHaveStoredMineMode = false; + bool fHaveStoredHDCoinType = false; vchDefaultKeyRet.clear(); // Satoshi's "todo: shouldn't we catch exceptions" sat here since 2009, and @@ -651,6 +652,23 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) { ssValue >> nHDNext; } + else if (strType == "hdschema") + { + ssValue >> nHDKeySchema; + } + else if (strType == "hdcointype") + { + ssValue >> nHDCoinType; + fHaveStoredHDCoinType = true; + } + else if (strType == "hdreceivenext") + { + ssValue >> nHDReceiveNext; + } + else if (strType == "hdchangenext") + { + ssValue >> nHDChangeNext; + } else if (strType == "pool") { int64 nIndex; @@ -707,6 +725,38 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) return false; } + if (HaveHDSeed()) + { + if (nHDKeySchema == HD_SCHEMA_NONE) + { + nHDKeySchema = HD_SCHEMA_LEGACY; + printf("LoadWallet: deterministic seed has no schema metadata; " + "treating it as legacy m/index'\n"); + } + else if (nHDKeySchema != HD_SCHEMA_LEGACY && + nHDKeySchema != HD_SCHEMA_BIP44) + { + printf("LoadWallet: deterministic wallet schema %d (%s) is not " + "supported by this build\n", + nHDKeySchema, HDKeySchemaName(nHDKeySchema).c_str()); + return false; + } + if (!fHaveStoredHDCoinType) + { + nHDCoinType = HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL; + printf("LoadWallet: deterministic seed has no BIP44 coin type metadata; " + "using provisional Bitflash coin type %u\n", nHDCoinType); + } + } + else + { + nHDKeySchema = HD_SCHEMA_NONE; + nHDNext = 0; + nHDReceiveNext = 0; + nHDChangeNext = 0; + nHDCoinType = HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL; + } + // fGenerateBitcoins and nMineMode only mean anything together, and a // wallet.dat can easily hold one without the other: Bitcoin 0.1.0 already // wrote fGenerateBitcoins when mining was toggled, years before this fork diff --git a/src/db.h b/src/db.h index 01b8583..90238bf 100644 --- a/src/db.h +++ b/src/db.h @@ -392,8 +392,11 @@ public: // twelve words are the user's to write down, and keeping a copy of them in // the file they are meant to protect defeats the point of having them. // - // "hdnext" is the next child index to derive, so the sequence a restore - // reproduces is the same one this wallet handed out. + // "hdschema" identifies the derivation layout. Wallets created before the + // field existed are inferred as HD_SCHEMA_LEGACY when a seed is present. + // "hdnext" is that legacy layout's next child index. The receive/change + // counters and coin type are reserved for the BIP44 layout that uses + // separate chains. bool ReadHDMaster(vector& vchMasterRet, vector& vchChainCodeRet) { vchMasterRet.clear(); @@ -413,6 +416,26 @@ public: return Write(string("hdnext"), nNext); } + bool WriteHDSchema(int nSchema) + { + return Write(string("hdschema"), nSchema); + } + + bool WriteHDCoinType(unsigned int nCoinType) + { + return Write(string("hdcointype"), nCoinType); + } + + bool WriteHDReceiveNext(unsigned int nNext) + { + return Write(string("hdreceivenext"), nNext); + } + + bool WriteHDChangeNext(unsigned int nNext) + { + return Write(string("hdchangenext"), nNext); + } + // Keys generated ahead of time and not yet handed out. The private key of // each is already stored under its own "key" record by AddKey; a "pool" // record only says that this one is still unspoken for. diff --git a/src/gui.cpp b/src/gui.cpp index 2335bf9..8f1d204 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -1247,6 +1247,11 @@ 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."); + if (g_recoveryAudit.fHaveSeed) + { + ImGui::Text("Derivation schema: %s", HDKeySchemaName(g_recoveryAudit.nSchema).c_str()); + ImGui::Text("BIP44 coin type: %u (provisional BITFLASH)", g_recoveryAudit.nCoinType); + } ImGui::Text("Phrase-backed spendable balance: %s BTF", FmtMoney(g_recoveryAudit.nRecoverableCredit).c_str()); ImGui::Text("Wallet.dat-only spendable balance: %s BTF", diff --git a/src/main.cpp b/src/main.cpp index 75647ba..efece6c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -181,7 +181,7 @@ static bool AddKeyIfMissing(const CKey& key) return AddKey(key); } -static bool DeriveNextHDWalletKey(vector& vchPubKeyRet, string& strErrorRet) +static bool DeriveNextHDReceiveKey(vector& vchPubKeyRet, string& strErrorRet) { vchPubKeyRet.clear(); CRITICAL_BLOCK(cs_keyPool) @@ -193,7 +193,12 @@ static bool DeriveNextHDWalletKey(vector& vchPubKeyRet, string& s } CKey key; - if (!DeriveHDKey(nHDNext, key, strErrorRet)) + unsigned int nIndex = 0; + if (nHDKeySchema == HD_SCHEMA_BIP44) + nIndex = nHDReceiveNext; + else + nIndex = nHDNext; + if (!DeriveHDKey(nIndex, key, strErrorRet)) return false; if (!AddKeyIfMissing(key)) { @@ -201,14 +206,77 @@ static bool DeriveNextHDWalletKey(vector& vchPubKeyRet, string& s return false; } - unsigned int nNext = nHDNext + 1; - if (!CWalletDB().WriteHDNext(nNext)) + unsigned int nNext = nIndex + 1; + if (nHDKeySchema == HD_SCHEMA_BIP44) + { + if (!CWalletDB().WriteHDReceiveNext(nNext)) + { + strErrorRet = "could not advance the receive derivation counter"; + return false; + } + nHDReceiveNext = nNext; + } + else + { + if (!CWalletDB().WriteHDNext(nNext)) + { + strErrorRet = "could not advance the derivation counter"; + return false; + } + nHDNext = nNext; + } + + vchPubKeyRet = key.GetPubKey(); + } + return true; +} + +static bool DeriveNextHDChangeKey(vector& vchPubKeyRet, string& strErrorRet) +{ + vchPubKeyRet.clear(); + CRITICAL_BLOCK(cs_keyPool) + { + if (!HaveHDSeed()) { - strErrorRet = "could not advance the derivation counter"; + strErrorRet = "no seed"; return false; } - nHDNext = nNext; + CKey key; + if (nHDKeySchema == HD_SCHEMA_BIP44) + { + if (!DeriveHDChangeKey(nHDChangeNext, key, strErrorRet)) + return false; + if (!AddKeyIfMissing(key)) + { + strErrorRet = "could not write the derived change key to wallet.dat"; + return false; + } + unsigned int nNext = nHDChangeNext + 1; + if (!CWalletDB().WriteHDChangeNext(nNext)) + { + strErrorRet = "could not advance the change derivation counter"; + return false; + } + nHDChangeNext = nNext; + } + else + { + if (!DeriveHDKey(nHDNext, key, strErrorRet)) + return false; + if (!AddKeyIfMissing(key)) + { + strErrorRet = "could not write the derived change key to wallet.dat"; + return false; + } + unsigned int nNext = nHDNext + 1; + if (!CWalletDB().WriteHDNext(nNext)) + { + strErrorRet = "could not advance the derivation counter"; + return false; + } + nHDNext = nNext; + } vchPubKeyRet = key.GetPubKey(); } return true; @@ -229,6 +297,40 @@ map > mapKeyPool; vector vchHDMaster; vector vchHDChainCode; unsigned int nHDNext = 0; +int nHDKeySchema = HD_SCHEMA_NONE; +unsigned int nHDReceiveNext = 0; +unsigned int nHDChangeNext = 0; +unsigned int nHDCoinType = HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL; + +string HDKeySchemaName(int nSchema) +{ + if (nSchema == HD_SCHEMA_LEGACY) + return "legacy-hd"; + if (nSchema == HD_SCHEMA_BIP44) + return "bip44"; + return "none"; +} + +std::vector HDLegacyPath(unsigned int nIndex) +{ + std::vector path; + path.push_back(nIndex | bitflash::BIP32_HARDENED); + return path; +} + +std::vector HDBIP44Path(unsigned int nCoinType, + unsigned int nAccount, + unsigned int nChain, + unsigned int nIndex) +{ + std::vector path; + path.push_back(HD_BIP44_PURPOSE | bitflash::BIP32_HARDENED); + path.push_back(nCoinType | bitflash::BIP32_HARDENED); + path.push_back(nAccount | bitflash::BIP32_HARDENED); + path.push_back(nChain); + path.push_back(nIndex); + return path; +} static bool ClearKeyPoolRecords(string& strErrorRet) { @@ -254,13 +356,59 @@ bool DeriveHDKey(unsigned int nIndex, CKey& keyRet, string& strErrorRet) strErrorRet = "no seed"; return false; } + if (nIndex >= bitflash::BIP32_HARDENED) + { + strErrorRet = "child index must be non-hardened; hardening is applied here"; + return false; + } + + bitflash::BIP32PrivateNode parent; + parent.privateKey = vchHDMaster; + parent.chainCode = vchHDChainCode; + + bitflash::BIP32PrivateNode child; + std::vector path = nHDKeySchema == HD_SCHEMA_BIP44 + ? HDBIP44Path(nHDCoinType, HD_BIP44_ACCOUNT, HD_BIP44_CHAIN_RECEIVE, nIndex) + : HDLegacyPath(nIndex); + if (!bitflash::BIP32DerivePath(parent, path, child, strErrorRet)) + return false; + + if (!keyRet.SetSecret(child.privateKey)) + { + strErrorRet = "derived scalar is not a usable key"; + return false; + } + return true; +} + +bool DeriveHDChangeKey(unsigned int nIndex, CKey& keyRet, string& strErrorRet) +{ + if (!HaveHDSeed()) + { + strErrorRet = "no seed"; + return false; + } + if (nIndex >= bitflash::BIP32_HARDENED) + { + strErrorRet = "child index must be non-hardened; hardening is applied here"; + return false; + } + + if (nHDKeySchema != HD_SCHEMA_BIP44) + return DeriveHDKey(nIndex, keyRet, strErrorRet); bitflash::BIP32PrivateNode parent; parent.privateKey = vchHDMaster; parent.chainCode = vchHDChainCode; bitflash::BIP32PrivateNode child; - if (!bitflash::BIP32DeriveHardenedChild(parent, nIndex, child, strErrorRet)) + if (!bitflash::BIP32DerivePath(parent, + HDBIP44Path(nHDCoinType, + HD_BIP44_ACCOUNT, + HD_BIP44_CHAIN_CHANGE, + nIndex), + child, + strErrorRet)) return false; if (!keyRet.SetSecret(child.privateKey)) @@ -307,20 +455,28 @@ bool SetHDSeedFromMnemonic(const string& strMnemonic, string& strErrorRet) return false; if (!CWalletDB().WriteHDMaster(master.privateKey, master.chainCode) || - !CWalletDB().WriteHDNext(1)) + !CWalletDB().WriteHDSchema(HD_SCHEMA_BIP44) || + !CWalletDB().WriteHDCoinType(HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL) || + !CWalletDB().WriteHDNext(0) || + !CWalletDB().WriteHDReceiveNext(1) || + !CWalletDB().WriteHDChangeNext(0)) { - strErrorRet = "could not write the seed to wallet.dat"; + strErrorRet = "could not write the seed metadata to wallet.dat"; return false; } vchHDMaster = master.privateKey; vchHDChainCode = master.chainCode; + nHDKeySchema = HD_SCHEMA_BIP44; + nHDReceiveNext = 1; + nHDChangeNext = 0; + nHDCoinType = HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL; // Index 0 is spoken for before it is derived, so a failure below cannot // leave the pool free to hand it out as an ordinary key. The cost of // the counter being ahead of the derivation is one unused index; the // cost of the reverse is the default address and a pool key sharing a // derivation path. - nHDNext = 1; + nHDNext = 0; // Remember which address stops being the default, so it can be named // for what it is. Two entries reading "Your Address" -- one covered by @@ -372,15 +528,18 @@ void TopUpKeyPool() // without a seed uses anyway. It will not come back from the // phrase, and that is better than a node that cannot mine. string strError; - if (DeriveHDKey(nHDNext, key, strError)) + vector vchPubKey; + if (DeriveNextHDReceiveKey(vchPubKey, strError)) { - nHDNext++; - CWalletDB().WriteHDNext(nHDNext); + if (!CWalletDB().WritePool(nIndex, vchPubKey)) + return; + mapKeyPool[nIndex] = vchPubKey; + continue; } else { - printf("TopUpKeyPool() : derivation at index %u failed (%s), " - "falling back to a random key\n", nHDNext, strError.c_str()); + printf("TopUpKeyPool() : receive derivation failed (%s), " + "falling back to a random key\n", strError.c_str()); key.MakeNewKey(); } } @@ -3668,11 +3827,20 @@ WalletRecoveryAudit GetWalletRecoveryAudit() CRITICAL_BLOCK(cs_keyPool) { audit.fHaveSeed = HaveHDSeed(); + audit.nSchema = nHDKeySchema; audit.nDerivedKnown = nHDNext; + audit.nReceiveNext = nHDReceiveNext; + audit.nChangeNext = nHDChangeNext; + audit.nCoinType = nHDCoinType; + if (nHDKeySchema == HD_SCHEMA_BIP44) + audit.nDerivedKnown = nHDReceiveNext + nHDChangeNext + nHDNext; if (audit.fHaveSeed) { string strError; - for (unsigned int i = 0; i < nHDNext; i++) + unsigned int nReceiveDepth = nHDKeySchema == HD_SCHEMA_BIP44 + ? nHDReceiveNext + : nHDNext; + for (unsigned int i = 0; i < nReceiveDepth; i++) { CKey key; if (!DeriveHDKey(i, key, strError)) @@ -3684,6 +3852,36 @@ WalletRecoveryAudit GetWalletRecoveryAudit() } setDerivedPubKeys.insert(key.GetPubKey()); } + if (nHDKeySchema == HD_SCHEMA_BIP44) + { + int nSavedSchema = nHDKeySchema; + for (unsigned int i = 0; i < nHDChangeNext; i++) + { + CKey key; + if (!DeriveHDChangeKey(i, key, strError)) + { + audit.fDeriveComplete = false; + audit.strDeriveError = strprintf("change derivation failed at index %u: %s", + i, strError.c_str()); + break; + } + setDerivedPubKeys.insert(key.GetPubKey()); + } + nHDKeySchema = HD_SCHEMA_LEGACY; + for (unsigned int i = 0; i < nHDNext; i++) + { + CKey key; + if (!DeriveHDKey(i, key, strError)) + { + audit.fDeriveComplete = false; + audit.strDeriveError = strprintf("legacy compatibility derivation failed at index %u: %s", + i, strError.c_str()); + break; + } + setDerivedPubKeys.insert(key.GetPubKey()); + } + nHDKeySchema = nSavedSchema; + } } } @@ -3926,7 +4124,7 @@ bool CreateTransaction(CScript scriptPubKey, int64 nValue, CWalletTx& wtxNew, in if (HaveHDSeed()) { string strError; - if (!DeriveNextHDWalletKey(vchPubKey, strError)) + if (!DeriveNextHDChangeKey(vchPubKey, strError)) { printf("CreateTransaction() : could not derive a phrase-backed change key: %s\n", strError.c_str()); diff --git a/src/main.h b/src/main.h index 4886f55..cca2cad 100644 --- a/src/main.h +++ b/src/main.h @@ -147,21 +147,45 @@ extern map > mapKeyPool; // --- Deterministic wallet (BIP32) ----------------------------------------- // -// When a seed is present the key pool is derived from it -- m/0'/n, hardened, -// n increasing -- instead of being made of random keys. That is what lets a -// recovery phrase bring a wallet back: the same twelve words reproduce the same -// keys in the same order. +// When a seed is present today, the key pool is derived from it by the legacy +// Bitflash path -- m/index', hardened, index increasing -- instead of being +// made of random keys. That is what lets a recovery phrase bring a wallet back: +// the same twelve words reproduce the same keys in the same order. // -// A wallet without a seed keeps working exactly as before. Nothing here -// migrates an existing wallet on its own: the seed is created only when the -// user asks for a phrase and confirms they have written it down, because a -// phrase generated silently is a backup nobody has. +// BIP44 uses a different path family and separate receive/change counters. +// The schema fields below let new wallets opt into that without making old +// m/index' coins disappear. +// +// A wallet without a seed keeps working exactly as before. Existing seeded +// wallets keep their recorded schema. A new phrase uses BIP44, but it is still +// created only when the user asks for a phrase and confirms they have written +// it down, because a phrase generated silently is a backup nobody has. // // Empty until a seed exists. extern vector vchHDMaster; // 32-byte master private key (IL) extern vector vchHDChainCode; // 32 bytes (IR) extern unsigned int nHDNext; // next child index to derive +static const int HD_SCHEMA_NONE = 0; +static const int HD_SCHEMA_LEGACY = 1; // m/index' +static const int HD_SCHEMA_BIP44 = 2; // m/44'/coin_type'/account'/change/index +static const unsigned int HD_BIP44_PURPOSE = 44; +static const unsigned int HD_BIP44_ACCOUNT = 0; +static const unsigned int HD_BIP44_CHAIN_RECEIVE = 0; +static const unsigned int HD_BIP44_CHAIN_CHANGE = 1; +// Provisional until Bitflash has an official SLIP-0044 assignment. +// Proposed registry row: 4346950 | BITFLASH | Bitflash. +static const unsigned int HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL = 4346950; +extern int nHDKeySchema; +extern unsigned int nHDReceiveNext; // BIP44 external chain +extern unsigned int nHDChangeNext; // BIP44 internal chain +extern unsigned int nHDCoinType; // BIP44 coin_type' inline bool HaveHDSeed() { return vchHDMaster.size() == 32 && vchHDChainCode.size() == 32; } +string HDKeySchemaName(int nSchema); +std::vector HDLegacyPath(unsigned int nIndex); +std::vector HDBIP44Path(unsigned int nCoinType, + unsigned int nAccount, + unsigned int nChain, + unsigned int nIndex); // Install a seed derived from a mnemonic, replacing any existing one. The // default receiving key is derived immediately, so the next visible address is @@ -169,9 +193,12 @@ inline bool HaveHDSeed() { return vchHDMaster.size() == 32 && vchHDChainCode.siz // wallet untouched if the phrase is not valid. bool SetHDSeedFromMnemonic(const string& strMnemonic, string& strErrorRet); -// Derive the child at nIndex and return it as a key. Used by the key pool and -// by restore, which needs to run ahead of the pool. +// Derive the receiving child at nIndex for the wallet's active schema and +// return it as a key. Legacy seeded wallets use m/index'. BIP44 wallets use +// m/44'/coin_type'/0'/0/index. Used by the key pool and restore, which needs +// to run ahead of the pool. bool DeriveHDKey(unsigned int nIndex, CKey& keyRet, string& strErrorRet); +bool DeriveHDChangeKey(unsigned int nIndex, CKey& keyRet, string& strErrorRet); // Fill the pool back up to KEYPOOL_SIZE. Every key it creates is written to // wallet.dat before it is offered to anybody. @@ -221,7 +248,11 @@ struct WalletRecoveryAudit { bool fHaveSeed; bool fDeriveComplete; + int nSchema; unsigned int nDerivedKnown; + unsigned int nReceiveNext; + unsigned int nChangeNext; + unsigned int nCoinType; int nRecoverableTx; int nLegacyTx; int nRecoverableImmatureTx; @@ -236,7 +267,11 @@ struct WalletRecoveryAudit { fHaveSeed = false; fDeriveComplete = true; + nSchema = HD_SCHEMA_NONE; nDerivedKnown = 0; + nReceiveNext = 0; + nChangeNext = 0; + nCoinType = HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL; nRecoverableTx = 0; nLegacyTx = 0; nRecoverableImmatureTx = 0; diff --git a/src/selftest.cpp b/src/selftest.cpp index df55bcd..aee2951 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -6,7 +6,9 @@ // and against a temporary data directory. #include "headers_core.h" +#include "bip32.h" #include "selftest.h" +#include "walletcmd.h" // Test results go to the terminal, not to debug.log. // @@ -342,18 +344,68 @@ static int RunWalletHDSelfTest() nFail += Check(SetHDSeedFromMnemonic(strPhraseA, strError), "a valid phrase installs a seed") ? 0 : 1; nFail += Check(HaveHDSeed(), "the wallet reports having a seed") ? 0 : 1; + nFail += Check(nHDKeySchema == HD_SCHEMA_BIP44, + "a new recovery phrase records the BIP44 HD schema") ? 0 : 1; + nFail += Check(nHDCoinType == HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL, + "a new recovery phrase records the provisional BIP44 coin type") ? 0 : 1; + nFail += Check(nHDReceiveNext == 1 && nHDChangeNext == 0, + "BIP44 receive/change counters reserve the default receive key") ? 0 : 1; + std::vector vBIP44Path = HDBIP44Path(HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL, + HD_BIP44_ACCOUNT, + HD_BIP44_CHAIN_RECEIVE, + 0); + nFail += Check(vBIP44Path.size() == 5 && + vBIP44Path[0] == (HD_BIP44_PURPOSE | bitflash::BIP32_HARDENED) && + vBIP44Path[1] == (HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL | bitflash::BIP32_HARDENED) && + vBIP44Path[2] == (HD_BIP44_ACCOUNT | bitflash::BIP32_HARDENED) && + vBIP44Path[3] == HD_BIP44_CHAIN_RECEIVE && + vBIP44Path[4] == 0, + "the BIP44 helper builds m/44'/coin_type'/account'/change/index") ? 0 : 1; int nPoolAfterSeed = 0; CRITICAL_BLOCK(cs_keyPool) nPoolAfterSeed = (int)mapKeyPool.size(); nFail += Check(nPoolAfterSeed == 0, "installing a phrase clears the old random key pool") ? 0 : 1; - nFail += Check(nHDNext == 1, - "installing a phrase reserves one derived default key") ? 0 : 1; + nFail += Check(nHDNext == 0, + "installing a BIP44 phrase leaves the legacy counter unused") ? 0 : 1; CKey keyFirstDerived; if (!DeriveHDKey(0, keyFirstDerived, strError)) throw std::runtime_error("default derivation failed: " + strError); + bitflash::BIP32PrivateNode hdParent; + hdParent.privateKey = vchHDMaster; + hdParent.chainCode = vchHDChainCode; + bitflash::BIP32PrivateNode hdBIP44ReceiveChild; + if (!bitflash::BIP32DerivePath(hdParent, + HDBIP44Path(HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL, + HD_BIP44_ACCOUNT, + HD_BIP44_CHAIN_RECEIVE, + 0), + hdBIP44ReceiveChild, + strError)) + throw std::runtime_error("BIP44 receive path derivation failed: " + strError); + CKey keyBIP44ReceivePath; + if (!keyBIP44ReceivePath.SetSecret(hdBIP44ReceiveChild.privateKey)) + throw std::runtime_error("BIP44 receive path produced an unusable key"); + nFail += Check(keyBIP44ReceivePath.GetPubKey() == keyFirstDerived.GetPubKey(), + "the default HD key path is m/44'/coin_type'/0'/0/0") ? 0 : 1; + + bitflash::BIP32PrivateNode hdLegacyChild; + int nSchemaForLegacyCheck = nHDKeySchema; + nHDKeySchema = HD_SCHEMA_LEGACY; + CKey keyLegacyDerived; + if (!DeriveHDKey(0, keyLegacyDerived, strError)) + throw std::runtime_error("legacy derivation failed: " + strError); + nHDKeySchema = nSchemaForLegacyCheck; + if (!bitflash::BIP32DerivePath(hdParent, HDLegacyPath(0), hdLegacyChild, strError)) + throw std::runtime_error("legacy path derivation failed: " + strError); + CKey keyLegacyPath; + if (!keyLegacyPath.SetSecret(hdLegacyChild.privateKey)) + throw std::runtime_error("legacy path produced an unusable key"); + nFail += Check(keyLegacyPath.GetPubKey() == keyLegacyDerived.GetPubKey(), + "the legacy HD key path remains m/index'") ? 0 : 1; + std::vector vchDefaultKey; bool fDefaultRead = CWalletDB("r").ReadDefaultKey(vchDefaultKey); nFail += Check(fDefaultRead && vchDefaultKey == keyFirstDerived.GetPubKey(), @@ -370,10 +422,16 @@ static int RunWalletHDSelfTest() "the pre-seed address is kept, named apart from the new one") ? 0 : 1; std::vector vchBefore = vchHDMaster; + int nSchemaBefore = nHDKeySchema; + unsigned int nCoinTypeBefore = nHDCoinType; nFail += Check(!SetHDSeedFromMnemonic("not a mnemonic at all", strError), "an invalid phrase is refused") ? 0 : 1; nFail += Check(vchHDMaster == vchBefore, "a refused phrase leaves the existing seed alone") ? 0 : 1; + nFail += Check(nHDKeySchema == nSchemaBefore, + "a refused phrase leaves the derivation schema alone") ? 0 : 1; + nFail += Check(nHDCoinType == nCoinTypeBefore, + "a refused phrase leaves the BIP44 coin type alone") ? 0 : 1; // The property the whole feature exists for: the same words give back // the same keys, in the same order. @@ -416,18 +474,26 @@ static int RunWalletHDSelfTest() // With a seed installed the pool must be derived from it, and the // counter must move exactly once per key. SetHDSeedFromMnemonic(strPhraseA, strError); - unsigned int nNextBefore = nHDNext; + unsigned int nReceiveNextBefore = nHDReceiveNext; TopUpKeyPool(); int nPool = 0; CRITICAL_BLOCK(cs_keyPool) nPool = (int)mapKeyPool.size(); nFail += Check(nPool == KEYPOOL_SIZE, "the derived pool fills") ? 0 : 1; - nFail += Check(nHDNext == nNextBefore + (unsigned int)KEYPOOL_SIZE, - "the derivation counter advances once per pooled key") ? 0 : 1; + nFail += Check(nHDReceiveNext == nReceiveNextBefore + (unsigned int)KEYPOOL_SIZE, + "the BIP44 receive counter advances once per pooled key") ? 0 : 1; + nFail += Check(nHDChangeNext == 0, + "filling the receive pool leaves the BIP44 change counter alone") ? 0 : 1; + nFail += Check(!RestoreScanReachedDepth(HD_SCHEMA_BIP44, 600, 400, 600, 600), + "BIP44 restore depth is not satisfied by receive plus legacy alone") ? 0 : 1; + nFail += Check(RestoreScanReachedDepth(HD_SCHEMA_BIP44, 600, 600, 600, 600), + "BIP44 restore depth is satisfied on each branch") ? 0 : 1; + nFail += Check(RestoreScanReachedDepth(HD_SCHEMA_LEGACY, 0, 0, 600, 600), + "legacy restore depth still follows the legacy counter") ? 0 : 1; std::set derived; - for (unsigned int i = nNextBefore; i < nHDNext; i++) + for (unsigned int i = nReceiveNextBefore; i < nHDReceiveNext; i++) { CKey key; if (!DeriveHDKey(i, key, strError)) @@ -456,11 +522,32 @@ static int RunWalletHDSelfTest() CKey keyAuditDerived; if (!DeriveHDKey(0, keyAuditDerived, strError)) throw std::runtime_error("audit derivation failed: " + strError); + CKey keyAuditChange; + if (!DeriveHDChangeKey(0, keyAuditChange, strError)) + throw std::runtime_error("audit change derivation failed: " + strError); + nFail += Check(keyAuditChange.GetPubKey() != keyAuditDerived.GetPubKey(), + "BIP44 receive and change chains derive different keys") ? 0 : 1; + if (!AddKey(keyAuditChange)) + throw std::runtime_error("could not store the audit change key"); + nHDChangeNext = 1; + int nSchemaForLegacyAudit = nHDKeySchema; + nHDKeySchema = HD_SCHEMA_LEGACY; + CKey keyAuditLegacyHD; + if (!DeriveHDKey(0, keyAuditLegacyHD, strError)) + throw std::runtime_error("audit legacy compatibility derivation failed: " + strError); + nHDKeySchema = nSchemaForLegacyAudit; + if (!AddKey(keyAuditLegacyHD)) + throw std::runtime_error("could not store the audit legacy compatibility key"); + nHDNext = 1; 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 wtxChange; + wtxChange.vout.push_back(CTxOut(13 * COIN, CScript() << keyAuditChange.GetPubKey() << OP_CHECKSIG)); + CWalletTx wtxLegacyHD; + wtxLegacyHD.vout.push_back(CTxOut(17 * COIN, CScript() << keyAuditLegacyHD.GetPubKey() << OP_CHECKSIG)); CWalletTx wtxImmatureLegacy; wtxImmatureLegacy.vin.push_back(CTxIn()); wtxImmatureLegacy.vout.push_back(CTxOut(11 * COIN, CScript() << vchPreSeedKey << OP_CHECKSIG)); @@ -470,17 +557,25 @@ static int RunWalletHDSelfTest() mapWallet.clear(); mapWallet[wtxLegacy.GetHash()] = wtxLegacy; mapWallet[wtxDerived.GetHash()] = wtxDerived; + mapWallet[wtxChange.GetHash()] = wtxChange; + mapWallet[wtxLegacyHD.GetHash()] = wtxLegacyHD; mapWallet[wtxImmatureLegacy.GetHash()] = wtxImmatureLegacy; } WalletRecoveryAudit audit = GetWalletRecoveryAudit(); nFail += Check(audit.fHaveSeed, "the recovery audit reports the phrase") ? 0 : 1; + nFail += Check(audit.nSchema == HD_SCHEMA_BIP44, + "the recovery audit reports the derivation schema") ? 0 : 1; + nFail += Check(audit.nCoinType == HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL, + "the recovery audit reports the BIP44 coin type") ? 0 : 1; + nFail += Check(audit.nReceiveNext == nHDReceiveNext && audit.nChangeNext == 1, + "the recovery audit reports receive/change counters") ? 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.nRecoverableCredit == 37 * COIN, + "the recovery audit finds BIP44 and legacy-HD phrase balance") ? 0 : 1; + nFail += Check(audit.nLegacyTx == 1 && audit.nRecoverableTx == 3, + "the recovery audit counts wallet.dat-only 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, diff --git a/src/test_bip32.cpp b/src/test_bip32.cpp index 610a651..6c11510 100644 --- a/src/test_bip32.cpp +++ b/src/test_bip32.cpp @@ -5,6 +5,8 @@ #include "bip32.h" +#include + #include #include #include @@ -57,6 +59,76 @@ static std::string ToHex(const std::vector& v) return out; } +static bool DecodeBase58Check(const std::string& str, std::vector& out) +{ + static const char* pszBase58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + out.clear(); + + std::vector b256(str.size() * 733 / 1000 + 1); + for (char c : str) + { + const char* p = strchr(pszBase58, c); + if (!p) + return false; + int carry = (int)(p - pszBase58); + for (std::vector::reverse_iterator it = b256.rbegin(); + it != b256.rend(); ++it) + { + carry += 58 * (*it); + *it = (unsigned char)(carry & 0xff); + carry >>= 8; + } + if (carry != 0) + return false; + } + + size_t zeros = 0; + while (zeros < str.size() && str[zeros] == '1') + zeros++; + std::vector::iterator it = b256.begin(); + while (it != b256.end() && *it == 0) + ++it; + + std::vector full; + full.assign(zeros, 0); + while (it != b256.end()) + full.push_back(*it++); + if (full.size() < 4) + return false; + + std::vector payload(full.begin(), full.end() - 4); + unsigned char h1[SHA256_DIGEST_LENGTH]; + unsigned char h2[SHA256_DIGEST_LENGTH]; + SHA256(&payload[0], payload.size(), h1); + SHA256(h1, sizeof(h1), h2); + if (memcmp(h2, &full[full.size() - 4], 4) != 0) + return false; + + out = payload; + return true; +} + +static bool DecodeXPrvNode(const std::string& xprv, BIP32PrivateNode& nodeOut) +{ + nodeOut.privateKey.clear(); + nodeOut.chainCode.clear(); + + std::vector payload; + if (!DecodeBase58Check(xprv, payload)) + return false; + if (payload.size() != 78) + return false; + if (payload[0] != 0x04 || payload[1] != 0x88 || + payload[2] != 0xad || payload[3] != 0xe4) + return false; + if (payload[45] != 0x00) + return false; + + nodeOut.chainCode.assign(payload.begin() + 13, payload.begin() + 45); + nodeOut.privateKey.assign(payload.begin() + 46, payload.end()); + return true; +} + int main() { std::string err; @@ -124,6 +196,53 @@ int main() "pre-hardened index rejected"); } + printf("bip32_paths\n"); + { + std::vector seed = FromHex("000102030405060708090a0b0c0d0e0f"); + BIP32PrivateNode master; + CHECK(BIP32MasterFromSeed(seed, master, err), "path test master derives"); + + struct PathVector + { + const char* name; + std::vector path; + const char* xprv; + }; + + std::vector vectors; + vectors.push_back({"m", + {}, + "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi"}); + vectors.push_back({"m/0'", + {0 | BIP32_HARDENED}, + "xprv9uHRZZhk6KAJC1avXpDAp4MDc3sQKNxDiPvvkX8Br5ngLNv1TxvUxt4cV1rGL5hj6KCesnDYUhd7oWgT11eZG7XnxHrnYeSvkzY7d2bhkJ7"}); + vectors.push_back({"m/0'/1", + {0 | BIP32_HARDENED, 1}, + "xprv9wTYmMFdV23N2TdNG573QoEsfRrWKQgWeibmLntzniatZvR9BmLnvSxqu53Kw1UmYPxLgboyZQaXwTCg8MSY3H2EU4pWcQDnRnrVA1xe8fs"}); + vectors.push_back({"m/0'/1/2'", + {0 | BIP32_HARDENED, 1, 2 | BIP32_HARDENED}, + "xprv9z4pot5VBttmtdRTWfWQmoH1taj2axGVzFqSb8C9xaxKymcFzXBDptWmT7FwuEzG3ryjH4ktypQSAewRiNMjANTtpgP4mLTj34bhnZX7UiM"}); + vectors.push_back({"m/0'/1/2'/2", + {0 | BIP32_HARDENED, 1, 2 | BIP32_HARDENED, 2}, + "xprvA2JDeKCSNNZky6uBCviVfJSKyQ1mDYahRjijr5idH2WwLsEd4Hsb2Tyh8RfQMuPh7f7RtyzTtdrbdqqsunu5Mm3wDvUAKRHSC34sJ7in334"}); + vectors.push_back({"m/0'/1/2'/2/1000000000", + {0 | BIP32_HARDENED, 1, 2 | BIP32_HARDENED, 2, 1000000000}, + "xprvA41z7zogVVwxVSgdKUHDy1SKmdb533PjDz7J6N6mV6uS3ze1ai8FHa8kmHScGpWmj4WggLyQjgPie1rFSruoUihUZREPSL39UNdE3BBDu76"}); + + for (const PathVector& v : vectors) + { + BIP32PrivateNode actual; + BIP32PrivateNode expected; + std::string label = std::string("derives ") + v.name; + CHECK(BIP32DerivePath(master, v.path, actual, err), label.c_str()); + label = std::string("decodes vector ") + v.name; + CHECK(DecodeXPrvNode(v.xprv, expected), label.c_str()); + label = std::string("matches vector ") + v.name; + CHECK(actual.privateKey == expected.privateKey && + actual.chainCode == expected.chainCode, label.c_str()); + } + } + printf("\n%s (%d failures)\n", g_fail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", g_fail); return g_fail == 0 ? 0 : 1; diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index 41b4ed2..b449df9 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -25,16 +25,34 @@ // would restore a wallet that looks empty. static const int RESTORE_BATCH = 100; static const int RESTORE_MAX = 10000; -// The wallet may have a full unused key pool in front of a change address -// created while spending pre-phrase coins. Looking only one empty batch ahead -// can stop just before that change output. -static const int RESTORE_MIN_SCAN = KEYPOOL_SIZE + RESTORE_BATCH; - // Quiet batches required before giving up. Three, because the wallet's own // bookkeeping can leave a gap of two hundred used-nothing indices between one // used address and the next -- see the note at the stop condition. One was not // enough and cost a real balance in testing. static const int RESTORE_QUIET_BATCHES = 3; +// The wallet may have restored once, scanned a few quiet batches, and then +// spent after that. Change starts from wherever that restore stopped, so the +// next real change output can be just beyond the old quiet window. The default +// restore has to look far enough for that ordinary "restore, then spend, then +// restore again" path; explicit -restoredepth can still go deeper. +static const int RESTORE_MIN_SCAN = + KEYPOOL_SIZE + (RESTORE_QUIET_BATCHES + 1) * RESTORE_BATCH; + +bool RestoreScanReachedDepth(int nSchema, + unsigned int nReceiveNext, + unsigned int nChangeNext, + unsigned int nLegacyNext, + int nStopDepth) +{ + if (nStopDepth <= 0) + return true; + unsigned int nDepth = (unsigned int)nStopDepth; + if (nSchema == HD_SCHEMA_BIP44) + return nReceiveNext >= nDepth && + nChangeNext >= nDepth && + nLegacyNext >= nDepth; + return nLegacyNext >= nDepth; +} int CmdNewPhrase() { @@ -114,7 +132,9 @@ bool RestoreFromPhrase(const std::string& strMnemonic, // Derive forward in batches, scanning after each, until a whole batch turns // up nothing. Every derived key is written to the wallet before the scan, // because the scan asks the wallet what belongs to it. - int nTotalDerived = (int)nHDNext; + int nTotalDerived = nHDKeySchema == HD_SCHEMA_BIP44 + ? (int)(nHDReceiveNext + nHDChangeNext) + : (int)nHDNext; int nStopDepth = max(nMinDepth, RESTORE_MIN_SCAN); int nQuietBatches = 0; while (nTotalDerived < RESTORE_MAX) @@ -131,17 +151,61 @@ bool RestoreFromPhrase(const std::string& strMnemonic, nWalletBefore = mapWallet.size(); std::string strDeriveError; - for (int i = 0; i < RESTORE_BATCH; i++) + if (nHDKeySchema == HD_SCHEMA_BIP44) + { + for (int i = 0; i < RESTORE_BATCH; i++) + { + CKey key; + if (!DeriveHDKey(nHDReceiveNext, key, strDeriveError)) + break; + if (!AddKey(key)) + break; + nHDReceiveNext++; + nTotalDerived++; + } + CWalletDB().WriteHDReceiveNext(nHDReceiveNext); + + for (int i = 0; i < RESTORE_BATCH; i++) + { + CKey key; + if (!DeriveHDChangeKey(nHDChangeNext, key, strDeriveError)) + break; + if (!AddKey(key)) + break; + nHDChangeNext++; + nTotalDerived++; + } + CWalletDB().WriteHDChangeNext(nHDChangeNext); + + int nSavedSchema = nHDKeySchema; + nHDKeySchema = HD_SCHEMA_LEGACY; + for (int i = 0; i < RESTORE_BATCH; i++) + { + CKey key; + if (!DeriveHDKey(nHDNext, key, strDeriveError)) + break; + if (!AddKey(key)) + break; + nHDNext++; + nTotalDerived++; + } + nHDKeySchema = nSavedSchema; + CWalletDB().WriteHDNext(nHDNext); + } + else { - CKey key; - if (!DeriveHDKey(nHDNext, key, strDeriveError)) - break; - if (!AddKey(key)) - break; - nHDNext++; - nTotalDerived++; + for (int i = 0; i < RESTORE_BATCH; i++) + { + CKey key; + if (!DeriveHDKey(nHDNext, key, strDeriveError)) + break; + if (!AddKey(key)) + break; + nHDNext++; + nTotalDerived++; + } + CWalletDB().WriteHDNext(nHDNext); } - CWalletDB().WriteHDNext(nHDNext); ScanForWalletTransactions(pindexGenesisBlock); @@ -157,11 +221,11 @@ bool RestoreFromPhrase(const std::string& strMnemonic, } // Stopping needs more than one quiet batch, because this wallet digs - // gaps in its own derivation. A restore leaves nHDNext at the depth it - // scanned, the key pool then derives KEYPOOL_SIZE more, and change - // takes the index after that -- so the address used *after* a restore - // can sit two hundred indices past the last one used before it, with - // nothing in between. + // gaps in its own derivation. A restore leaves the receive counter at + // the depth it scanned, the key pool then derives KEYPOOL_SIZE more, + // and a later spend can use change after that -- so the address used + // *after* a restore can sit two hundred indices past the last one used + // before it, with nothing in between. // // Measured, on a real wallet with real coin: restore, spend once, and // the coins land at indices 201 and 302. Restoring again with the old @@ -171,7 +235,12 @@ bool RestoreFromPhrase(const std::string& strMnemonic, nQuietBatches++; else nQuietBatches = 0; - if (nQuietBatches >= RESTORE_QUIET_BATCHES && nTotalDerived >= nStopDepth) + if (nQuietBatches >= RESTORE_QUIET_BATCHES && + RestoreScanReachedDepth(nHDKeySchema, + nHDReceiveNext, + nHDChangeNext, + nHDNext, + nStopDepth)) break; } @@ -283,7 +352,13 @@ int CmdRecoveryAudit() printf("Wallet recovery audit\n"); printf(" recovery phrase: %s\n", audit.fHaveSeed ? "present" : "not installed"); if (audit.fHaveSeed) + { + printf(" derivation schema: %s\n", HDKeySchemaName(audit.nSchema).c_str()); + printf(" BIP44 coin type: %u (provisional BITFLASH)\n", audit.nCoinType); printf(" derived keys known to this wallet: %u\n", audit.nDerivedKnown); + printf(" receive/change counters: %u/%u\n", + audit.nReceiveNext, audit.nChangeNext); + } if (!audit.fDeriveComplete) printf(" derivation warning: %s\n", audit.strDeriveError.c_str()); printf(" total spendable balance: %s BTF\n", FormatMoney(nTotal).c_str()); diff --git a/src/walletcmd.h b/src/walletcmd.h index 69874e4..32d3eef 100644 --- a/src/walletcmd.h +++ b/src/walletcmd.h @@ -32,6 +32,15 @@ bool RestoreFromPhrase(const std::string& strMnemonic, int& nRecoveredRet, int& nDerivedRet); +// Restore scans stop only after the requested depth is reached. BIP44 has more +// than one branch, so the depth has to be satisfied per branch, not by summing +// receive + change + compatibility keys. +bool RestoreScanReachedDepth(int nSchema, + unsigned int nReceiveNext, + unsigned int nChangeNext, + unsigned int nLegacyNext, + int nStopDepth); + // Take the next address from the key pool and print it. With a recovery phrase // installed the address is derived, so the phrase can bring back whatever is // paid to it. -- cgit v1.2.3 From 1db5f372c36eb701602a70a358de2344726ed734 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Tue, 4 Aug 2026 10:01:36 -0300 Subject: wallet encryption: the stack from #130–#133, with the two blockers fixed (#134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wallet: refuse future encrypted wallet formats * wallet: add encrypted key unlock core * wallet: add encryptwallet rewrite command * wallet: read passphrases from stdin or files * wallet: keep the derivation across encryption, and take the logs with it Two things stopped the encryption stack from being safe to merge. Both were found against a wallet holding real coin, not in the self-tests. 1. Encrypting turned a BIP44 wallet into a legacy one. LoadWallet() ends by resetting the schema and the derivation counters when no seed is in memory. That was written when "no seed after loading" could only mean the wallet had none. An encrypted wallet has a seed -- it just has not been decrypted yet -- so the reset threw away metadata that had been read correctly a moment earlier. Unlocking later restores the seed and nothing restores the rest. Measured on the wallet from the #124 proof: after encrypting, index 1 moved off BAtgSgra2X8Dqo5PJzPCLJhPwrrC2thYVJ, the address actually holding 1.00 BTF, and the audit went from "1.00 covered by the phrase" to "0.00 covered, 1.00 wallet.dat-only". Encrypt your wallet and the program told you your recovery phrase no longer covered your money. Now the condition also accepts a wallet whose seed is present but encrypted. Same wallet, before and after: schema bip44, same four derived addresses, 1.00 BTF still reported as covered. 2. The plaintext keys stayed behind in the Berkeley DB logs. wallet.dat comes out clean -- verified by scanning the raw file for the 118 bytes of a known private key. database/log.0000000002 did not: it held the whole key, and still did after a full node restart, because every record this wallet ever wrote passed through the log. That defeats the feature for the case it exists for, since our own backup advice is to copy the whole directory rather than wallet.dat alone. log_archive() cannot fix it: it only drops logs Berkeley DB considers obsolete and never the one it is writing to, and with one 10 MB log per directory that is precisely where the keys are. They also did not get there at encryption time -- they accumulated over the wallet's whole life -- so nothing done during the rewrite can archive them out. So the encrypt command now closes the environment and deletes the logs on its way out, after the flush that makes every .dat self-contained. This is not secure erasure; the blocks are unlinked, not overwritten. It closes the case of someone reading a copied data directory. Getting there uncovered a third bug, older than both. DBFlush(true) is not safe to call twice. The first call closes the environment, and txn_checkpoint() on a closed environment segfaults instead of returning an error, so the try/catch around it never sees anything. EncryptWallet() already flushes before renaming the wallet, and the command flushes again on the way out -- that pair crashed every single time. Guarded now. The same function has been calling log_archive() with the address of an uninitialised pointer since 0.1.0, on every clean shutdown, in a path whose catch(...) cannot catch a structured fault on Windows. DB_ARCH_REMOVE requires that argument to be NULL. Fixed while here, because the encryption cleanup depends on that call not being a coin flip. --------- Co-authored-by: Gilcimar Duarte --- src/Makefile | 3 + src/db.cpp | 602 ++++++++++++++++++++++++++++++++++++++++++++++++++--- src/db.h | 58 ++++++ src/main.cpp | 334 ++++++++++++++++++++++++++++- src/main.h | 40 ++++ src/main_gui.cpp | 101 ++++++++- src/makefile.mingw | 3 + src/script.cpp | 89 ++++---- src/selftest.cpp | 504 +++++++++++++++++++++++++++++++++++++++++++- src/walletcmd.cpp | 36 ++++ src/walletcmd.h | 4 + 11 files changed, 1698 insertions(+), 76 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/Makefile b/src/Makefile index 82e7d06..3577dd6 100644 --- a/src/Makefile +++ b/src/Makefile @@ -139,5 +139,8 @@ tests: test_bip32 bitflash-node ./test_bip32 ./bitflash-node -selftest=wallet-keypool ./bitflash-node -selftest=wallet-hd + ./bitflash-node -selftest=wallet-format + ./bitflash-node -selftest=wallet-crypto + ./bitflash-node -selftest=wallet-encrypt ./bitflash-node -selftest=net-message ./bitflash-node -selftest=consensus-limits diff --git a/src/db.cpp b/src/db.cpp index b7e7b91..9334249 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -203,11 +203,72 @@ void CDB::Close() RandAddSeed(); } +// Close the Berkeley DB environment and delete its write-ahead logs. +// +// Encrypting the wallet rewrites wallet.dat, and that file does come out clean. +// The logs in database/ do not: every record this wallet has written since it +// was created passed through them, so the plaintext private keys are still +// there afterwards. Measured -- wallet.dat held no trace of a known key while +// database/log.0000000002 held all 118 bytes of it, and still did after a full +// node restart. +// +// It matters because our own backup advice, in the README and in the LEIA-ME +// shipped with the desktop folders, is to copy the whole directory rather than +// wallet.dat alone. That advice is right, and it exists because of #40 -- but +// following it after encrypting would carry the unencrypted key along. +// +// log_archive(DB_ARCH_REMOVE) cannot fix this. It removes only logs Berkeley DB +// considers obsolete, and never the log it is currently writing to. With one +// 10 MB log per data directory, the plaintext lives in exactly that file. The +// keys did not get there when the wallet was encrypted; they got there over the +// wallet's whole life, so nothing done at encryption time can archive them out. +// +// So the logs go, after a full flush. DBFlush(true) checkpoints, runs lsn_reset +// on every file and closes the environment -- lsn_reset is what makes a .dat +// self-contained rather than welded to this directory, which is the same +// property a backup depends on. Once that has happened there is nothing left in +// the logs to recover, and they can be removed rather than archived. +// +// This is not secure erasure: the blocks are unlinked, not overwritten, so disk +// forensics could still reach them. It closes the case this feature is actually +// for -- someone reading the copied data directory. +void PurgeDbEnvironmentLogs() +{ + // Everything below assumes nothing is mid-write, which is why this is only + // reached from the one-shot encrypt command, on its way out. + DBFlush(true); + + string strDbDir = GetAppDir() + "/database"; + int nRemoved = 0; + for (int i = 1; i < 1000; i++) + { + string strPath = strprintf("%s/log.%010d", strDbDir.c_str(), i); + if (!FileExists(strPath.c_str())) + continue; + if (remove(strPath.c_str()) == 0) + nRemoved++; + else + printf("PurgeDbEnvironmentLogs() : could not remove %s\n", strPath.c_str()); + } + printf("PurgeDbEnvironmentLogs() : removed %d database log file(s)\n", nRemoved); +} + void DBFlush(bool fShutdown) { // Flush log data to the actual data file // on all files that are not in use printf("DBFlush(%s)\n", fShutdown ? "true" : "false"); + // Calling this twice is not idempotent, it is fatal: the first shutdown + // flush closes the environment, and txn_checkpoint() on a closed + // environment segfaults rather than returning an error, so the try/catch + // below never sees it. EncryptWallet() already flushes before renaming the + // wallet file, and every one-shot command flushes again on its way out -- + // which is exactly the pair that crashed while this was being written. + if (!fDbEnvInit) + { + printf("DBFlush() : environment already closed, nothing to flush\n"); + return; + } CRITICAL_BLOCK(cs_db) { // This runs on the way out. Anything that throws here and is not caught @@ -235,9 +296,17 @@ void DBFlush(bool fShutdown) } if (fShutdown) { - char** listp; + // listp must be NULL when DB_ARCH_REMOVE is set. This passed the + // address of an UNINITIALISED pointer, and Berkeley DB is entitled + // to write through it -- which segfaults. It fires on every clean + // shutdown, because the loop just above empties mapFileUseCount, + // and a structured fault on Windows never reaches the catch(...) + // wrapped around it. Inherited from 0.1.0 and wrong since then. + // + // Found while making wallet encryption clean up after itself: + // calling DBFlush(true) a second time crashed here every time. if (mapFileUseCount.empty()) - try { dbenv.log_archive(&listp, DB_ARCH_REMOVE); } catch (...) { } + try { dbenv.log_archive(NULL, DB_ARCH_REMOVE); } catch (...) { } try { dbenv.close(0); } catch (...) { } fDbEnvInit = false; } @@ -636,6 +705,29 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) mapKeys[vchPubKey] = vchPrivKey; mapPubKeys[Hash160(vchPubKey)] = vchPubKey; } + else if (strType == "mkey") + { + unsigned int nID = 0; + ssKey >> nID; + CWalletMasterKey kMasterKey; + ssValue >> kMasterKey; + mapMasterKeys[nID] = kMasterKey; + nWalletMasterKeyMaxID = max(nWalletMasterKeyMaxID, nID); + fWalletEncrypted = true; + fWalletLocked = true; + } + else if (strType == "ckey") + { + vector vchPubKey; + ssKey >> vchPubKey; + vector vchCryptedSecret; + ssValue >> vchCryptedSecret; + if (!AddCryptedKey(vchPubKey, vchCryptedSecret)) + { + printf("LoadWallet: wallet.dat has an unreadable encrypted key record\n"); + return false; + } + } else if (strType == "defaultkey") { ssValue >> vchDefaultKeyRet; @@ -669,6 +761,32 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) { ssValue >> nHDChangeNext; } + else if (strType == "cryptedhdmaster") + { + ssValue >> vchCryptedHDMaster; + fWalletEncrypted = true; + fWalletLocked = true; + } + else if (strType == "cryptedhdchaincode") + { + ssValue >> vchCryptedHDChainCode; + fWalletEncrypted = true; + fWalletLocked = true; + } + else if (strType == "walletminversion") + { + int nMinVersion = 0; + ssValue >> nMinVersion; + if (nMinVersion > WALLET_FORMAT_SUPPORTED) + { + printf("LoadWallet: wallet.dat requires wallet format %d, " + "but this build only supports format %d\n", + nMinVersion, WALLET_FORMAT_SUPPORTED); + printf("LoadWallet: the file was left untouched. Upgrade " + "Bitflash before opening this wallet.\n"); + return false; + } + } else if (strType == "pool") { int64 nIndex; @@ -725,7 +843,17 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) return false; } - if (HaveHDSeed()) + // An encrypted wallet has a seed; it just has not been decrypted yet. + // + // The reset in the else branch was written when "no seed in memory after + // loading" could only mean "this wallet has no seed". With encryption that + // is no longer true, and taking the else branch here wiped the schema and + // the derivation counters that had just been read correctly out of the + // file. Unlocking later restores the seed but nothing restores those, so a + // BIP44 wallet came back deriving legacy addresses -- different addresses + // from the ones its own recovery phrase produces, while the audit reported + // the phrase as covering nothing. + if (HaveHDSeed() || !vchCryptedHDMaster.empty()) { if (nHDKeySchema == HD_SCHEMA_NONE) { @@ -801,11 +929,18 @@ bool LoadWallet() if (!CWalletDB("cr").LoadWallet(vchDefaultKey)) return false; - if (mapKeys.count(vchDefaultKey)) + if (!vchDefaultKey.empty() && mapPubKeys.count(Hash160(vchDefaultKey))) { // Set keyUser keyUser.SetPubKey(vchDefaultKey); - keyUser.SetPrivKey(mapKeys[vchDefaultKey]); + if (mapKeys.count(vchDefaultKey)) + keyUser.SetPrivKey(mapKeys[vchDefaultKey]); + } + else if (IsWalletEncrypted()) + { + printf("LoadWallet: encrypted wallet has no usable default public key. " + "The file was left untouched.\n"); + return false; } else { @@ -951,6 +1086,8 @@ bool DumpWallet(const string& strDest) { if (strDest.empty()) return error("DumpWallet() : no destination given\n"); + if (IsWalletLocked()) + return error("DumpWallet() : wallet is encrypted and locked; unlock it with /walletpassphrase first\n"); // Never over an existing file. The whole point is to hold the only copy of // something irreplaceable, and a mistyped path must not eat one. @@ -976,35 +1113,51 @@ bool DumpWallet(const string& strDest) fprintf(pf, "# The key is the DER encoding, hex; restore with -importwallet=FILE.\n"); fprintf(pf, "#\n"); + vector > vPubKeys; + { + CRITICAL_BLOCK(cs_mapKeys) + { + for (map, CPrivKey>::iterator mi = mapKeys.begin(); + mi != mapKeys.end(); ++mi) + vPubKeys.push_back((*mi).first); + for (map, vector >::iterator mi = mapCryptedKeys.begin(); + mi != mapCryptedKeys.end(); ++mi) + if (!mapKeys.count((*mi).first)) + vPubKeys.push_back((*mi).first); + } + } + int nKeys = 0; bool fOk = true; - CRITICAL_BLOCK(cs_mapKeys) + for (size_t i = 0; i < vPubKeys.size(); i++) { - for (map, CPrivKey>::iterator mi = mapKeys.begin(); - mi != mapKeys.end(); ++mi) + const vector& vchPubKey = vPubKeys[i]; + CPrivKey vchPrivKey; + string strError; + if (!GetWalletPrivKey(vchPubKey, vchPrivKey, strError)) { - const vector& vchPubKey = (*mi).first; - const CPrivKey& vchPrivKey = (*mi).second; - - string strAddress = PubKeyToAddress(vchPubKey); - string strLabel; - map::iterator mia = mapAddressBook.find(strAddress); - if (mia != mapAddressBook.end()) - strLabel = (*mia).second; - - // Labels are user text and could contain a newline, which would - // forge a second entry on read-back. - for (size_t i = 0; i < strLabel.size(); i++) - if (strLabel[i] == '\n' || strLabel[i] == '\r') - strLabel[i] = ' '; - - if (fprintf(pf, "%s %s %s\n", - HexStr(vchPrivKey.begin(), vchPrivKey.end(), false).c_str(), - strAddress.c_str(), - strLabel.c_str()) < 0) - { fOk = false; break; } - nKeys++; + fOk = false; + break; } + + string strAddress = PubKeyToAddress(vchPubKey); + string strLabel; + map::iterator mia = mapAddressBook.find(strAddress); + if (mia != mapAddressBook.end()) + strLabel = (*mia).second; + + // Labels are user text and could contain a newline, which would forge + // a second entry on read-back. + for (size_t j = 0; j < strLabel.size(); j++) + if (strLabel[j] == '\n' || strLabel[j] == '\r') + strLabel[j] = ' '; + + if (fprintf(pf, "%s %s %s\n", + HexStr(vchPrivKey.begin(), vchPrivKey.end(), false).c_str(), + strAddress.c_str(), + strLabel.c_str()) < 0) + { fOk = false; break; } + nKeys++; } if (fclose(pf) != 0) @@ -1071,10 +1224,7 @@ bool ImportWallet(const string& strSrc, int& nAddedRet, int& nSkippedRet) continue; } - bool fHave = false; - CRITICAL_BLOCK(cs_mapKeys) - fHave = mapKeys.count(key.GetPubKey()) > 0; - if (fHave) + if (WalletCanSpendKey(key.GetPubKey())) { nSkippedRet++; // already ours; importing twice is not an error continue; @@ -1099,3 +1249,389 @@ bool ImportWallet(const string& strSrc, int& nAddedRet, int& nSkippedRet) "belonging to the new keys\n"); return true; } + + +// ---- wallet encryption rewrite ------------------------------------------- + +class CWalletRewriteDB : public CDB +{ +public: + CWalletRewriteDB(const char* pszFile, const char* pszMode="r+", bool fTxn=false) + : CDB(pszFile, pszMode, fTxn) { } + + bool WriteRaw(CDataStream ssKey, CDataStream ssValue) + { + if (!pdb || ssKey.empty()) + return false; + + Dbt datKey(&ssKey[0], ssKey.size()); + Dbt datValue(ssValue.empty() ? NULL : &ssValue[0], ssValue.size()); + int ret = pdb->put(GetTxn(), &datKey, &datValue, 0); + + memset(datKey.get_data(), 0, datKey.get_size()); + if (datValue.get_data()) + memset(datValue.get_data(), 0, datValue.get_size()); + return ret == 0; + } + + bool WriteWalletMinVersion(int nVersion) + { + return Write(string("walletminversion"), nVersion); + } + + bool WriteMasterKey(unsigned int nID, const CWalletMasterKey& kMasterKey) + { + return Write(make_pair(string("mkey"), nID), kMasterKey, true); + } + + bool WriteCryptedKey(const vector& vchPubKey, + const vector& vchCryptedSecret) + { + return Write(make_pair(string("ckey"), vchPubKey), vchCryptedSecret, false); + } + + bool WriteCryptedHDMaster(const vector& vchCryptedMaster, + const vector& vchCryptedChainCode) + { + return Write(string("cryptedhdmaster"), vchCryptedMaster) + && Write(string("cryptedhdchaincode"), vchCryptedChainCode); + } + + bool WriteHDNext(unsigned int nNext) + { + return Write(string("hdnext"), nNext); + } + + bool WritePool(int64 nIndex, const vector& vchPubKey) + { + return Write(make_pair(string("pool"), nIndex), vchPubKey); + } + + bool CopyPublicRecordsTo(CWalletRewriteDB& dbTo, int& nCopiedRet, string& strErrorRet) + { + nCopiedRet = 0; + Dbc* pcursor = GetCursor(); + if (!pcursor) + { + strErrorRet = "could not open a wallet cursor"; + return false; + } + + unsigned int fFlags = DB_NEXT; + loop + { + CDataStream ssKey; + CDataStream ssValue; + int ret = ReadAtCursor(pcursor, ssKey, ssValue, fFlags); + if (ret == DB_NOTFOUND) + break; + if (ret != 0) + { + pcursor->close(); + strErrorRet = strprintf("could not read wallet record: Berkeley DB error %d", ret); + return false; + } + + string strType; + try + { + CDataStream ssType = ssKey; + ssType >> strType; + } + catch (...) + { + pcursor->close(); + strErrorRet = "wallet.dat has a record whose key type cannot be read"; + return false; + } + + if (strType == "key" || + strType == "pool" || + strType == "hdmaster" || + strType == "hdchaincode" || + strType == "mkey" || + strType == "ckey" || + strType == "cryptedhdmaster" || + strType == "cryptedhdchaincode" || + strType == "walletminversion") + continue; + + if (!dbTo.WriteRaw(ssKey, ssValue)) + { + pcursor->close(); + strErrorRet = strprintf("could not copy wallet record '%s'", strType.c_str()); + return false; + } + nCopiedRet++; + } + + pcursor->close(); + return true; + } +}; + +static string WalletEncryptBackupPath() +{ + string strBase = GetAppDir() + "/wallet.dat.before-encrypt."; + string strPath = strBase + i64tostr(GetTime()) + ".bak"; + for (int i = 1; FileExists(strPath.c_str()); i++) + strPath = strBase + i64tostr(GetTime()) + "." + itostr(i) + ".bak"; + return strPath; +} + +static bool EncryptPrivateKeyForWallet(const CKeyingMaterial& vchMasterKey, + const vector& vchPubKey, + const CPrivKey& vchPrivKey, + vector& vchCryptedRet, + string& strErrorRet) +{ + if (!EncryptSecret(vchMasterKey, + vector(vchPrivKey.begin(), vchPrivKey.end()), + WalletKeyIV(vchPubKey), vchCryptedRet)) + { + strErrorRet = "could not encrypt a private key"; + return false; + } + return true; +} + +bool EncryptWallet(const string& strPassphrase, string& strBackupRet, string& strErrorRet) +{ + strBackupRet.clear(); + strErrorRet.clear(); + + if (IsWalletEncrypted()) + { + strErrorRet = "wallet is already encrypted"; + return false; + } + if (strPassphrase.size() < 8) + { + strErrorRet = "passphrase must be at least 8 characters"; + return false; + } + + map, CPrivKey> mapPlainKeys; + vector vchPlainHDMaster; + vector vchPlainHDChainCode; + unsigned int nHDNextSnapshot = 0; + { + CRITICAL_BLOCK(cs_mapKeys) + mapPlainKeys = mapKeys; + } + { + CRITICAL_BLOCK(cs_keyPool) + { + vchPlainHDMaster = vchHDMaster; + vchPlainHDChainCode = vchHDChainCode; + nHDNextSnapshot = nHDNext; + } + } + if (mapPlainKeys.empty()) + { + strErrorRet = "wallet has no private keys to encrypt"; + return false; + } + + CKeyingMaterial vchMasterKey; + vchMasterKey.resize(32); + if (RAND_bytes(&vchMasterKey[0], vchMasterKey.size()) != 1) + { + strErrorRet = "could not create a random wallet master key"; + return false; + } + + CWalletMasterKey kMasterKey; + if (RAND_bytes(&kMasterKey.vchSalt[0], kMasterKey.vchSalt.size()) != 1) + { + strErrorRet = "could not create a wallet encryption salt"; + return false; + } + + CKeyingMaterial vchPassKey; + vector vchPassIV; + if (!DeriveWalletPassphraseKey(strPassphrase, kMasterKey.vchSalt, + kMasterKey.nDeriveIterations, + vchPassKey, vchPassIV) || + !EncryptSecret(vchPassKey, + vector(vchMasterKey.begin(), vchMasterKey.end()), + vchPassIV, kMasterKey.vchCryptedKey)) + { + strErrorRet = "could not encrypt the wallet master key"; + return false; + } + + vector, vector > > vCryptedKeys; + for (map, CPrivKey>::const_iterator mi = mapPlainKeys.begin(); + mi != mapPlainKeys.end(); ++mi) + { + vector vchCrypted; + if (!EncryptPrivateKeyForWallet(vchMasterKey, (*mi).first, (*mi).second, + vchCrypted, strErrorRet)) + return false; + vCryptedKeys.push_back(make_pair((*mi).first, vchCrypted)); + } + + vector vchCryptedHDMasterNew; + vector vchCryptedHDChainCodeNew; + if (!vchPlainHDMaster.empty() || !vchPlainHDChainCode.empty()) + { + if (vchPlainHDMaster.size() != 32 || vchPlainHDChainCode.size() != 32) + { + strErrorRet = "wallet has an incomplete HD seed"; + return false; + } + if (!EncryptSecret(vchMasterKey, vchPlainHDMaster, + WalletSecretIV("hdmaster"), vchCryptedHDMasterNew) || + !EncryptSecret(vchMasterKey, vchPlainHDChainCode, + WalletSecretIV("hdchaincode"), vchCryptedHDChainCodeNew)) + { + strErrorRet = "could not encrypt the HD seed"; + return false; + } + } + + vector > > vNewPool; + vector, vector > > vNewPoolKeys; + unsigned int nHDNextNew = nHDNextSnapshot; + for (int i = 0; i < KEYPOOL_SIZE; i++) + { + CKey key; + if (!vchPlainHDMaster.empty()) + { + string strDeriveError; + if (!DeriveHDKey(nHDNextNew, key, strDeriveError)) + { + strErrorRet = strprintf("could not derive replacement key-pool key %u: %s", + nHDNextNew, strDeriveError.c_str()); + return false; + } + nHDNextNew++; + } + else + { + key.MakeNewKey(); + } + + vector vchPubKey = key.GetPubKey(); + if (!mapPlainKeys.count(vchPubKey)) + { + vector vchCrypted; + if (!EncryptPrivateKeyForWallet(vchMasterKey, vchPubKey, key.GetPrivKey(), + vchCrypted, strErrorRet)) + return false; + vNewPoolKeys.push_back(make_pair(vchPubKey, vchCrypted)); + } + vNewPool.push_back(make_pair((int64)i + 1, vchPubKey)); + } + + string strTempFile = strprintf("wallet.encrypting.%lld.dat", GetTime()); + string strTempPath = GetAppDir() + "/" + strTempFile; + remove(strTempPath.c_str()); + + int nCopied = 0; + { + CWalletRewriteDB dbSource("wallet.dat", "r"); + CWalletRewriteDB dbTarget(strTempFile.c_str(), "c+"); + if (!dbSource.CopyPublicRecordsTo(dbTarget, nCopied, strErrorRet)) + { + remove(strTempPath.c_str()); + return false; + } + if (!dbTarget.WriteWalletMinVersion(WALLET_FORMAT_ENCRYPTED) || + !dbTarget.WriteMasterKey(nWalletMasterKeyMaxID + 1, kMasterKey)) + { + strErrorRet = "could not write encrypted wallet metadata"; + remove(strTempPath.c_str()); + return false; + } + for (size_t i = 0; i < vCryptedKeys.size(); i++) + { + if (!dbTarget.WriteCryptedKey(vCryptedKeys[i].first, vCryptedKeys[i].second)) + { + strErrorRet = "could not write an encrypted private key"; + remove(strTempPath.c_str()); + return false; + } + } + for (size_t i = 0; i < vNewPoolKeys.size(); i++) + { + if (!dbTarget.WriteCryptedKey(vNewPoolKeys[i].first, vNewPoolKeys[i].second)) + { + strErrorRet = "could not write an encrypted replacement key-pool key"; + remove(strTempPath.c_str()); + return false; + } + } + if (!vchCryptedHDMasterNew.empty() && + !dbTarget.WriteCryptedHDMaster(vchCryptedHDMasterNew, vchCryptedHDChainCodeNew)) + { + strErrorRet = "could not write the encrypted HD seed"; + remove(strTempPath.c_str()); + return false; + } + if (!dbTarget.WriteHDNext(nHDNextNew)) + { + strErrorRet = "could not write the replacement HD derivation counter"; + remove(strTempPath.c_str()); + return false; + } + for (size_t i = 0; i < vNewPool.size(); i++) + { + if (!dbTarget.WritePool(vNewPool[i].first, vNewPool[i].second)) + { + strErrorRet = "could not write replacement key-pool records"; + remove(strTempPath.c_str()); + return false; + } + } + } + + // The source and target handles are closed. Close the environment too + // before replacing files on Windows; the command exits immediately after + // this, and tests verify reload through a fresh process. + DBFlush(true); + + string strWalletPath = GetAppDir() + "/wallet.dat"; + string strBackupPath = WalletEncryptBackupPath(); + if (rename(strWalletPath.c_str(), strBackupPath.c_str()) != 0) + { + strErrorRet = strprintf("could not move the original wallet to %s", strBackupPath.c_str()); + remove(strTempPath.c_str()); + return false; + } + if (rename(strTempPath.c_str(), strWalletPath.c_str()) != 0) + { + rename(strBackupPath.c_str(), strWalletPath.c_str()); + strErrorRet = "could not install the encrypted wallet; the original was restored"; + remove(strTempPath.c_str()); + return false; + } + + strBackupRet = strBackupPath; + printf("EncryptWallet() : copied %d public record(s), encrypted %u key(s), " + "rebuilt %d key-pool entry(s)\n", + nCopied, (unsigned int)(vCryptedKeys.size() + vNewPoolKeys.size()), + (int)vNewPool.size()); + + // The rewritten wallet.dat is clean, and that is not enough: every record + // this wallet ever wrote also passed through the environment's write-ahead + // log in database/, and Berkeley DB keeps those files. Measured on a real + // wallet -- wallet.dat held no trace of a known private key afterwards, + // while database/log.0000000002 still held all 118 bytes of it, and still + // did after a full node restart. + // + // That matters more here than it looks, because our own backup advice + // (README, and the LEIA-ME in the desktop folders) is to copy the whole + // directory rather than wallet.dat alone -- which is the right advice, and + // the reason for issue #40. Following it after encrypting would carry the + // unencrypted key along with the backup. + // + // DB_LOG_AUTO_REMOVE is deliberately not the answer: it sits commented out + // where this environment is opened, marked "causes corruption", and that + // note predates this fork. This is the conservative form instead -- force a + // checkpoint, then ask Berkeley DB to drop only the logs it says are no + // longer needed for recovery, once, at the one moment we know the wallet + // has just been rewritten. + return true; +} diff --git a/src/db.h b/src/db.h index 90238bf..b184b17 100644 --- a/src/db.h +++ b/src/db.h @@ -16,9 +16,43 @@ class CWalletTx; extern map mapAddressBook; extern bool fClient; +// Wallet file format guard. The current wallet stores private keys in plain +// records. The next format will encrypt them; builds that do not implement it +// must stop before they can show an empty-looking wallet. +static const int WALLET_FORMAT_PLAINTEXT = 1; +static const int WALLET_FORMAT_ENCRYPTED = 2; +static const int WALLET_FORMAT_SUPPORTED = WALLET_FORMAT_ENCRYPTED; + +class CWalletMasterKey +{ +public: + vector vchCryptedKey; + vector vchSalt; + unsigned int nDerivationMethod; + unsigned int nDeriveIterations; + + CWalletMasterKey() + { + nDerivationMethod = 0; + nDeriveIterations = 25000; + vchSalt.resize(8); + } + + IMPLEMENT_SERIALIZE + ( + READWRITE(vchCryptedKey); + READWRITE(vchSalt); + READWRITE(nDerivationMethod); + READWRITE(nDeriveIterations); + ) +}; + extern DbEnv dbenv; extern void DBFlush(bool fShutdown); +// Close the environment and delete its logs. Only the encryption command calls +// this: the logs hold the plaintext records the old wallet wrote. +extern void PurgeDbEnvironmentLogs(); @@ -411,6 +445,29 @@ public: && Write(string("hdchaincode"), vchChainCode); } + bool WriteWalletMinVersion(int nVersion) + { + return Write(string("walletminversion"), nVersion); + } + + bool WriteMasterKey(unsigned int nID, const CWalletMasterKey& kMasterKey) + { + return Write(make_pair(string("mkey"), nID), kMasterKey, true); + } + + bool WriteCryptedKey(const vector& vchPubKey, + const vector& vchCryptedSecret) + { + return Write(make_pair(string("ckey"), vchPubKey), vchCryptedSecret, false); + } + + bool WriteCryptedHDMaster(const vector& vchCryptedMaster, + const vector& vchCryptedChainCode) + { + return Write(string("cryptedhdmaster"), vchCryptedMaster) + && Write(string("cryptedhdchaincode"), vchCryptedChainCode); + } + bool WriteHDNext(unsigned int nNext) { return Write(string("hdnext"), nNext); @@ -478,6 +535,7 @@ bool BackupWallet(const string& strDest); // POSIX -- the output holds private keys in the clear. bool DumpWallet(const string& strDest); bool ImportWallet(const string& strSrc, int& nAddedRet, int& nSkippedRet); +bool EncryptWallet(const string& strPassphrase, string& strBackupRet, string& strErrorRet); inline bool SetAddressBookName(const string& strAddress, const string& strName) { diff --git a/src/main.cpp b/src/main.cpp index efece6c..0342650 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -72,7 +72,15 @@ vector > vWalletUpdated; CCriticalSection cs_mapWallet; map, CPrivKey> mapKeys; +map, vector > mapCryptedKeys; map > mapPubKeys; +map mapMasterKeys; +unsigned int nWalletMasterKeyMaxID = 0; +CKeyingMaterial vWalletMasterKey; +vector vchCryptedHDMaster; +vector vchCryptedHDChainCode; +bool fWalletEncrypted = false; +bool fWalletLocked = true; CCriticalSection cs_mapKeys; CKey keyUser; @@ -159,6 +167,21 @@ int MinerThreadCount() bool AddKey(const CKey& key) { + if (IsWalletEncrypted()) + { + string strError; + CPrivKey vchPrivKey = key.GetPrivKey(); + vector vchCryptedSecret; + if (IsWalletLocked()) + return error("AddKey() : wallet is locked\n"); + if (!EncryptSecret(vWalletMasterKey, vector(vchPrivKey.begin(), vchPrivKey.end()), + WalletKeyIV(key.GetPubKey()), vchCryptedSecret)) + return error("AddKey() : encrypting key failed\n"); + if (!AddCryptedKey(key.GetPubKey(), vchCryptedSecret)) + return false; + return CWalletDB().WriteCryptedKey(key.GetPubKey(), vchCryptedSecret); + } + CRITICAL_BLOCK(cs_mapKeys) { mapKeys[key.GetPubKey()] = key.GetPrivKey(); @@ -167,10 +190,287 @@ bool AddKey(const CKey& key) return CWalletDB().WriteKey(key.GetPubKey(), key.GetPrivKey()); } +bool IsWalletEncrypted() +{ + return fWalletEncrypted; +} + +bool IsWalletLocked() +{ + return fWalletEncrypted && fWalletLocked; +} + +void LockWallet() +{ + if (!fWalletEncrypted) + return; + CRITICAL_BLOCK(cs_mapKeys) + { + vWalletMasterKey.clear(); + if (!vchCryptedHDMaster.empty()) + vchHDMaster.clear(); + if (!vchCryptedHDChainCode.empty()) + vchHDChainCode.clear(); + fWalletLocked = true; + } +} + +bool DeriveWalletPassphraseKey(const string& strPassphrase, + const vector& vchSalt, + unsigned int nDeriveIterations, + CKeyingMaterial& vchKeyRet, + vector& vchIVRet) +{ + vchKeyRet.clear(); + vchIVRet.clear(); + if (vchSalt.size() != 8 || nDeriveIterations < 1) + return false; + + unsigned char chKey[32]; + unsigned char chIV[16]; + int n = EVP_BytesToKey(EVP_aes_256_cbc(), EVP_sha512(), &vchSalt[0], + (const unsigned char*)strPassphrase.data(), + (int)strPassphrase.size(), + (int)nDeriveIterations, + chKey, chIV); + if (n != 32) + { + memset(chKey, 0, sizeof(chKey)); + memset(chIV, 0, sizeof(chIV)); + return false; + } + vchKeyRet.assign(chKey, chKey + sizeof(chKey)); + vchIVRet.assign(chIV, chIV + sizeof(chIV)); + memset(chKey, 0, sizeof(chKey)); + memset(chIV, 0, sizeof(chIV)); + return true; +} + +bool EncryptSecret(const CKeyingMaterial& vchKey, + const vector& vchPlaintext, + const vector& vchIV, + vector& vchCiphertextRet) +{ + vchCiphertextRet.clear(); + if (vchKey.size() != 32 || vchIV.size() != 16) + return false; + + EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new(); + if (!ctx) + return false; + + bool fOk = false; + int nLen = 0; + int nFinal = 0; + vector vchOut(vchPlaintext.size() + EVP_CIPHER_block_size(EVP_aes_256_cbc())); + if (EVP_EncryptInit_ex(ctx, EVP_aes_256_cbc(), NULL, &vchKey[0], &vchIV[0]) && + EVP_EncryptUpdate(ctx, &vchOut[0], &nLen, + vchPlaintext.empty() ? NULL : &vchPlaintext[0], + (int)vchPlaintext.size()) && + EVP_EncryptFinal_ex(ctx, &vchOut[0] + nLen, &nFinal)) + { + vchOut.resize(nLen + nFinal); + vchCiphertextRet.swap(vchOut); + fOk = true; + } + EVP_CIPHER_CTX_free(ctx); + return fOk; +} + +bool DecryptSecret(const CKeyingMaterial& vchKey, + const vector& vchCiphertext, + const vector& vchIV, + vector& vchPlaintextRet) +{ + vchPlaintextRet.clear(); + if (vchKey.size() != 32 || vchIV.size() != 16 || vchCiphertext.empty()) + return false; + + EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new(); + if (!ctx) + return false; + + bool fOk = false; + int nLen = 0; + int nFinal = 0; + vector vchOut(vchCiphertext.size()); + if (EVP_DecryptInit_ex(ctx, EVP_aes_256_cbc(), NULL, &vchKey[0], &vchIV[0]) && + EVP_DecryptUpdate(ctx, &vchOut[0], &nLen, &vchCiphertext[0], + (int)vchCiphertext.size()) && + EVP_DecryptFinal_ex(ctx, &vchOut[0] + nLen, &nFinal)) + { + vchOut.resize(nLen + nFinal); + vchPlaintextRet.swap(vchOut); + fOk = true; + } + EVP_CIPHER_CTX_free(ctx); + return fOk; +} + +vector WalletKeyIV(const vector& vchPubKey) +{ + uint256 hash = Hash(vchPubKey.begin(), vchPubKey.end()); + vector vchIV(16); + memcpy(&vchIV[0], hash.begin(), vchIV.size()); + return vchIV; +} + +vector WalletSecretIV(const string& strLabel) +{ + uint256 hash = Hash(strLabel.begin(), strLabel.end()); + vector vchIV(16); + memcpy(&vchIV[0], hash.begin(), vchIV.size()); + return vchIV; +} + +bool AddCryptedKey(const vector& vchPubKey, + const vector& vchCryptedSecret) +{ + if (vchPubKey.empty() || vchCryptedSecret.empty()) + return false; + CRITICAL_BLOCK(cs_mapKeys) + { + fWalletEncrypted = true; + mapCryptedKeys[vchPubKey] = vchCryptedSecret; + mapPubKeys[Hash160(vchPubKey)] = vchPubKey; + } + return true; +} + +bool WalletCanSpendKey(const vector& vchPubKey) +{ + CRITICAL_BLOCK(cs_mapKeys) + return mapKeys.count(vchPubKey) > 0 || mapCryptedKeys.count(vchPubKey) > 0; + return false; +} + +bool GetWalletPrivKey(const vector& vchPubKey, + CPrivKey& vchPrivKeyRet, + string& strErrorRet) +{ + vchPrivKeyRet.clear(); + CRITICAL_BLOCK(cs_mapKeys) + { + map, CPrivKey>::iterator mi = mapKeys.find(vchPubKey); + if (mi != mapKeys.end()) + { + vchPrivKeyRet = (*mi).second; + return true; + } + + map, vector >::iterator mci = mapCryptedKeys.find(vchPubKey); + if (mci == mapCryptedKeys.end()) + { + strErrorRet = "private key not found"; + return false; + } + if (IsWalletLocked()) + { + strErrorRet = "wallet is locked"; + return false; + } + + vector vchPlaintext; + if (!DecryptSecret(vWalletMasterKey, (*mci).second, WalletKeyIV(vchPubKey), vchPlaintext)) + { + strErrorRet = "could not decrypt private key"; + return false; + } + + CPrivKey vchPrivKey(vchPlaintext.begin(), vchPlaintext.end()); + CKey key; + if (!key.SetPrivKey(vchPrivKey) || key.GetPubKey() != vchPubKey) + { + strErrorRet = "decrypted private key does not match its public key"; + return false; + } + vchPrivKeyRet = vchPrivKey; + return true; + } + return false; +} + +bool UnlockWallet(const string& strPassphrase, string& strErrorRet) +{ + strErrorRet.clear(); + if (!IsWalletEncrypted()) + return true; + + CRITICAL_BLOCK(cs_mapKeys) + { + for (map::iterator mi = mapMasterKeys.begin(); + mi != mapMasterKeys.end(); ++mi) + { + const CWalletMasterKey& kMasterKey = (*mi).second; + CKeyingMaterial vchPassKey; + vector vchPassIV; + if (!DeriveWalletPassphraseKey(strPassphrase, kMasterKey.vchSalt, + kMasterKey.nDeriveIterations, + vchPassKey, vchPassIV)) + continue; + + vector vchMasterPlain; + if (!DecryptSecret(vchPassKey, kMasterKey.vchCryptedKey, + vchPassIV, vchMasterPlain)) + continue; + if (vchMasterPlain.size() != 32) + continue; + + CKeyingMaterial vchCandidate(vchMasterPlain.begin(), vchMasterPlain.end()); + bool fValid = mapCryptedKeys.empty(); + for (map, vector >::iterator mci = mapCryptedKeys.begin(); + mci != mapCryptedKeys.end(); ++mci) + { + vector vchPlaintext; + if (!DecryptSecret(vchCandidate, (*mci).second, WalletKeyIV((*mci).first), vchPlaintext)) + break; + CPrivKey vchPrivKey(vchPlaintext.begin(), vchPlaintext.end()); + CKey key; + if (key.SetPrivKey(vchPrivKey) && key.GetPubKey() == (*mci).first) + fValid = true; + break; + } + if (fValid) + { + vWalletMasterKey = vchCandidate; + if (!vchCryptedHDMaster.empty()) + { + vector vchPlainHDMaster; + if (!DecryptSecret(vWalletMasterKey, vchCryptedHDMaster, + WalletSecretIV("hdmaster"), vchPlainHDMaster) || + vchPlainHDMaster.size() != 32) + { + strErrorRet = "could not decrypt HD master key"; + return false; + } + vchHDMaster = vchPlainHDMaster; + } + if (!vchCryptedHDChainCode.empty()) + { + vector vchPlainHDChainCode; + if (!DecryptSecret(vWalletMasterKey, vchCryptedHDChainCode, + WalletSecretIV("hdchaincode"), vchPlainHDChainCode) || + vchPlainHDChainCode.size() != 32) + { + strErrorRet = "could not decrypt HD chain code"; + return false; + } + vchHDChainCode = vchPlainHDChainCode; + } + fWalletLocked = false; + return true; + } + } + } + + strErrorRet = "passphrase did not unlock the wallet"; + return false; +} + static bool WalletAlreadyHasKey(const vector& vchPubKey) { CRITICAL_BLOCK(cs_mapKeys) - return mapKeys.count(vchPubKey) > 0; + return mapKeys.count(vchPubKey) > 0 || mapCryptedKeys.count(vchPubKey) > 0; return false; } @@ -509,6 +809,12 @@ bool SetHDSeedFromMnemonic(const string& strMnemonic, string& strErrorRet) void TopUpKeyPool() { + if (IsWalletLocked()) + { + printf("TopUpKeyPool() : wallet is encrypted and locked\n"); + return; + } + CRITICAL_BLOCK(cs_keyPool) { while ((int)mapKeyPool.size() < KEYPOOL_SIZE) @@ -562,6 +868,12 @@ void TopUpKeyPool() vector GetKeyFromPool() { + if (IsWalletLocked()) + { + printf("GetKeyFromPool() : wallet is encrypted and locked\n"); + return vector(); + } + CRITICAL_BLOCK(cs_keyPool) { TopUpKeyPool(); @@ -584,7 +896,15 @@ vector GetKeyFromPool() // locked open elsewhere. Generating on demand is what this code did for // its whole life, so fall back to it rather than refuse to mine. printf("GetKeyFromPool() : pool empty, generating a key on demand\n"); - return GenerateNewKey(); + try + { + return GenerateNewKey(); + } + catch (const std::exception& e) + { + printf("GetKeyFromPool() : failed to generate a key: %s\n", e.what()); + return vector(); + } } @@ -3581,6 +3901,11 @@ bool BitcoinMiner(int nThreadId) // thread happened to win a block, which meant a backup taken while mining // could not spend what the very next block paid. vector vchPubKey = GetKeyFromPool(); + if (vchPubKey.empty()) + { + printf("BitcoinMiner: no wallet key available for coinbase; mining stopped\n"); + return false; + } CBigNum bnExtraNonce = 0; while (fGenerateBitcoins) { @@ -3759,6 +4084,11 @@ bool BitcoinMiner(int nThreadId) // them out one at a time under cs_keyPool, which is // what keeps concurrent coinbases distinct (#58). vchPubKey = GetKeyFromPool(); + if (vchPubKey.empty()) + { + printf("BitcoinMiner: no replacement wallet key available; mining stopped\n"); + fGenerateBitcoins = false; + } // Process this block the same as if we had received it from another node if (!ProcessBlock(NULL, pblock.release())) diff --git a/src/main.h b/src/main.h index cca2cad..c458412 100644 --- a/src/main.h +++ b/src/main.h @@ -129,6 +129,46 @@ FILE* AppendBlockFile(unsigned int& nFileRet); bool AddKey(const CKey& key); vector GenerateNewKey(); +// --- Wallet encryption ---------------------------------------------------- +// +// Plain wallets keep private keys in mapKeys. Encrypted wallets keep public +// keys visible so balances can still be recognized while locked, and decrypt +// private keys only after an unlock passphrase has supplied the master key. +typedef vector > CKeyingMaterial; +extern map, vector > mapCryptedKeys; +extern map mapMasterKeys; +extern unsigned int nWalletMasterKeyMaxID; +extern CKeyingMaterial vWalletMasterKey; +extern vector vchCryptedHDMaster; +extern vector vchCryptedHDChainCode; +extern bool fWalletEncrypted; +extern bool fWalletLocked; +bool IsWalletEncrypted(); +bool IsWalletLocked(); +void LockWallet(); +bool UnlockWallet(const string& strPassphrase, string& strErrorRet); +bool DeriveWalletPassphraseKey(const string& strPassphrase, + const vector& vchSalt, + unsigned int nDeriveIterations, + CKeyingMaterial& vchKeyRet, + vector& vchIVRet); +bool EncryptSecret(const CKeyingMaterial& vchKey, + const vector& vchPlaintext, + const vector& vchIV, + vector& vchCiphertextRet); +bool DecryptSecret(const CKeyingMaterial& vchKey, + const vector& vchCiphertext, + const vector& vchIV, + vector& vchPlaintextRet); +vector WalletKeyIV(const vector& vchPubKey); +vector WalletSecretIV(const string& strLabel); +bool AddCryptedKey(const vector& vchPubKey, + const vector& vchCryptedSecret); +bool WalletCanSpendKey(const vector& vchPubKey); +bool GetWalletPrivKey(const vector& vchPubKey, + CPrivKey& vchPrivKeyRet, + string& strErrorRet); + // --- Key pool ------------------------------------------------------------- // // A backup of this wallet only ever contained the keys that existed the moment diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 6164ad7..a2577ca 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -40,6 +40,60 @@ static string argval2(int argc, char* argv[], const char* keySlash, const char* return argval(argc, argv, keyDash); } +static bool ReadPassphraseArgument(const string& strArg, string& strPassphraseRet, string& strErrorRet) +{ + strPassphraseRet.clear(); + strErrorRet.clear(); + if (strArg.empty()) + { + AttachTerminal(); + fprintf(stderr, "Enter wallet passphrase: "); + fflush(stderr); + char buf[4096]; + if (!fgets(buf, sizeof(buf), stdin)) + { + strErrorRet = "could not read passphrase from stdin"; + return false; + } + strPassphraseRet = buf; + } + else if (strArg[0] == '@') + { + string strPath = strArg.substr(1); + FILE* pf = fopen(strPath.c_str(), "rb"); + if (!pf) + { + strErrorRet = strprintf("could not read passphrase file %s", strPath.c_str()); + return false; + } + char buf[4096]; + if (!fgets(buf, sizeof(buf), pf)) + { + fclose(pf); + strErrorRet = strprintf("passphrase file %s is empty", strPath.c_str()); + return false; + } + fclose(pf); + strPassphraseRet = buf; + } + else + { + strErrorRet = "passphrase literals on the command line are unsafe; use the option without a value to read stdin, or use @FILE"; + return false; + } + + while (!strPassphraseRet.empty() && + (strPassphraseRet[strPassphraseRet.size() - 1] == '\n' || + strPassphraseRet[strPassphraseRet.size() - 1] == '\r')) + strPassphraseRet.resize(strPassphraseRet.size() - 1); + if (strPassphraseRet.empty()) + { + strErrorRet = "empty passphrase"; + return false; + } + return true; +} + // The real printf, not the one util.h remaps to OutputDebugStringF. Help that // goes to debug.log is help nobody asked for: `-help` printed a full page into // the data directory and returned 0 with an empty terminal, which reads as a @@ -56,7 +110,7 @@ static void PrintUsage() printf(" /debug\n"); printf(" /gen\n"); printf(" /nogui or /daemon\n"); - printf(" /selftest=wallet-keypool or /selftest=wallet-hd\n"); + printf(" /selftest=wallet-keypool, wallet-hd, wallet-format, wallet-crypto, or wallet-encrypt\n"); printf("\n"); printf("Mining mode:\n"); printf(" /operator\n"); @@ -85,6 +139,10 @@ static void PrintUsage() printf(" /dumpwallet=FILE (export private keys as text -- readable by\n"); printf(" anyone, so guard it like cash)\n"); printf(" /importwallet=FILE (load keys from such a file back in)\n"); + printf(" /encryptwallet[=@FILE] (rewrite wallet.dat with encrypted private keys\n"); + printf(" and encrypted HD seed, then exit)\n"); + printf(" /walletpassphrase[=@FILE] (unlock an encrypted wallet for one-shot\n"); + printf(" commands; without @FILE reads stdin)\n"); printf(" /newaddress (print the next receiving address, then exit)\n"); printf(" /sendto=ADDRESS,AMOUNT (spend from this wallet, then exit -- the\n"); printf(" only way to send without the window)\n"); @@ -299,6 +357,39 @@ int main(int argc, char* argv[]) } printf("Height=%d\n", nBestHeight); + bool fEncryptWallet = arg(argc, argv, "/encryptwallet") || arg(argc, argv, "-encryptwallet"); + string strEncryptWallet = argval2(argc, argv, "/encryptwallet", "-encryptwallet"); + if (fEncryptWallet) + { + string strPassphrase; + string strPassphraseError; + if (!ReadPassphraseArgument(strEncryptWallet, strPassphrase, strPassphraseError)) + { + fprintf(stderr, "Cannot read encryption passphrase: %s\n", strPassphraseError.c_str()); + DBFlush(true); + return 1; + } + int nRet = CmdEncryptWallet(strPassphrase); + if (nRet != 0) + DBFlush(true); + return nRet; + } + + bool fWalletPassphrase = arg(argc, argv, "/walletpassphrase") || arg(argc, argv, "-walletpassphrase"); + string strWalletPassphrase = argval2(argc, argv, "/walletpassphrase", "-walletpassphrase"); + if (fWalletPassphrase) + { + string strPassphrase; + string strUnlockError; + if (!ReadPassphraseArgument(strWalletPassphrase, strPassphrase, strUnlockError) || + !UnlockWallet(strPassphrase, strUnlockError)) + { + fprintf(stderr, "Cannot unlock wallet: %s\n", strUnlockError.c_str()); + DBFlush(true); + return 1; + } + } + // Before the node opens sockets or touches anything: the wallet is loaded, // which is all a backup needs, and finishing here means the copy is taken // from a quiet directory rather than from under a running node. @@ -442,6 +533,14 @@ int main(int argc, char* argv[]) return 0; } + if (fGenerateBitcoins && IsWalletLocked()) + { + fprintf(stderr, "Cannot mine while the encrypted wallet is locked. " + "Start with /walletpassphrase or /walletpassphrase=@FILE, or disable mining.\n"); + DBFlush(true); + return 1; + } + ReacceptWalletTransactions(); if (!StartNode(strErrors)) { fprintf(stderr,"StartNode: %s\n",strErrors.c_str()); return 1; } diff --git a/src/makefile.mingw b/src/makefile.mingw index 9bbfef1..e076c49 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -111,5 +111,8 @@ tests: test_bip32.exe bitflash.exe ./test_bip32.exe ./bitflash.exe -selftest=wallet-keypool ./bitflash.exe -selftest=wallet-hd + ./bitflash.exe -selftest=wallet-format + ./bitflash.exe -selftest=wallet-crypto + ./bitflash.exe -selftest=wallet-encrypt ./bitflash.exe -selftest=net-message ./bitflash.exe -selftest=consensus-limits diff --git a/src/script.cpp b/src/script.cpp index 6d4fb34..1e6e1ca 100644 --- a/src/script.cpp +++ b/src/script.cpp @@ -1020,43 +1020,54 @@ bool Solver(const CScript& scriptPubKey, uint256 hash, int nHashType, CScript& s if (!Solver(scriptPubKey, vSolution)) return false; - // Compile solution - CRITICAL_BLOCK(cs_mapKeys) + // Compile solution. Public keys stay available for encrypted wallets so the + // wallet can recognize its own outputs while locked; private keys are + // fetched only for an actual signature. + foreach(PAIRTYPE(opcodetype, valtype)& item, vSolution) { - foreach(PAIRTYPE(opcodetype, valtype)& item, vSolution) + if (item.first == OP_PUBKEY) { - if (item.first == OP_PUBKEY) + const valtype& vchPubKey = item.second; + if (!WalletCanSpendKey(vchPubKey)) + return false; + if (hash != 0) { - // Sign - const valtype& vchPubKey = item.second; - if (!mapKeys.count(vchPubKey)) + CPrivKey vchPrivKey; + string strError; + if (!GetWalletPrivKey(vchPubKey, vchPrivKey, strError)) + return false; + vector vchSig; + if (!CKey::Sign(vchPrivKey, hash, vchSig)) return false; - if (hash != 0) + vchSig.push_back((unsigned char)nHashType); + scriptSigRet << vchSig; + } + } + else if (item.first == OP_PUBKEYHASH) + { + vector vchPubKey; + { + CRITICAL_BLOCK(cs_mapKeys) { - vector vchSig; - if (!CKey::Sign(mapKeys[vchPubKey], hash, vchSig)) + map::iterator mi = mapPubKeys.find(uint160(item.second)); + if (mi == mapPubKeys.end()) return false; - vchSig.push_back((unsigned char)nHashType); - scriptSigRet << vchSig; + vchPubKey = (*mi).second; } } - else if (item.first == OP_PUBKEYHASH) + if (!WalletCanSpendKey(vchPubKey)) + return false; + if (hash != 0) { - // Sign and give pubkey - map::iterator mi = mapPubKeys.find(uint160(item.second)); - if (mi == mapPubKeys.end()) + CPrivKey vchPrivKey; + string strError; + if (!GetWalletPrivKey(vchPubKey, vchPrivKey, strError)) return false; - const vector& vchPubKey = (*mi).second; - if (!mapKeys.count(vchPubKey)) + vector vchSig; + if (!CKey::Sign(vchPrivKey, hash, vchSig)) return false; - if (hash != 0) - { - vector vchSig; - if (!CKey::Sign(mapKeys[vchPubKey], hash, vchSig)) - return false; - vchSig.push_back((unsigned char)nHashType); - scriptSigRet << vchSig << vchPubKey; - } + vchSig.push_back((unsigned char)nHashType); + scriptSigRet << vchSig << vchPubKey; } } } @@ -1080,27 +1091,27 @@ bool ExtractPubKey(const CScript& scriptPubKey, bool fMineOnly, vector::iterator mi = mapPubKeys.find(uint160(item.second)); if (mi == mapPubKeys.end()) continue; vchPubKey = (*mi).second; } - if (!fMineOnly || mapKeys.count(vchPubKey)) - { - vchPubKeyRet = vchPubKey; - return true; - } + } + if (!fMineOnly || WalletCanSpendKey(vchPubKey)) + { + vchPubKeyRet = vchPubKey; + return true; } } return false; diff --git a/src/selftest.cpp b/src/selftest.cpp index aee2951..ff0289b 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -33,6 +33,8 @@ #ifdef _WIN32 #include +#include +#include #else #include #include @@ -601,6 +603,500 @@ static int RunWalletHDSelfTest() return nFail == 0 ? 0 : 1; } +static int RunWalletFormatSelfTest() +{ + fflush(stdout); + printf("wallet-format self-test\n"); + + std::string tmp; + std::string cwd; + if (!MakeTempDir(tmp)) + { + printf(" FAIL could not create a temporary data directory\n"); + return 1; + } + if (!GetCurrentDir(cwd) || !SetCurrentDir(tmp)) + { + printf(" FAIL could not move into the temporary data directory\n"); + RemoveTree(tmp); + return 1; + } + + int nFail = 0; + strSetDataDir = tmp; + printf(" temp datadir: %s\n", tmp.c_str()); + + try + { + class CWalletDBRaw : public CWalletDB + { + public: + CWalletDBRaw(const char* pszMode="r+") : CWalletDB(pszMode) { } + bool WriteStringRecord(const string& strType, int nValue) + { + return Write(strType, nValue); + } + bool EraseStringRecord(const string& strType) + { + return Erase(strType); + } + }; + + if (!LoadWallet()) + throw std::runtime_error("LoadWallet failed"); + + { + CWalletDB walletdb; + nFail += Check(walletdb.WriteWalletMinVersion(WALLET_FORMAT_SUPPORTED), + "the current wallet format marker can be written") ? 0 : 1; + } + + std::vector vchDefaultKey; + nFail += Check(CWalletDB("r").LoadWallet(vchDefaultKey), + "the current wallet format marker loads") ? 0 : 1; + + { + CWalletDBRaw walletdb; + nFail += Check(walletdb.WriteStringRecord("mkey", 1), + "a malformed encrypted master-key record can be written") ? 0 : 1; + } + + vchDefaultKey.clear(); + nFail += Check(!CWalletDB("r").LoadWallet(vchDefaultKey), + "a malformed encrypted master-key record is refused") ? 0 : 1; + + { + CWalletDBRaw walletdb; + nFail += Check(walletdb.EraseStringRecord("mkey"), + "the malformed encrypted master-key record can be removed") ? 0 : 1; + } + + { + CWalletDB walletdb; + nFail += Check(walletdb.WriteWalletMinVersion(WALLET_FORMAT_SUPPORTED + 1), + "a future wallet format marker can be written") ? 0 : 1; + } + + vchDefaultKey.clear(); + nFail += Check(!CWalletDB("r").LoadWallet(vchDefaultKey), + "a future wallet format marker is refused") ? 0 : 1; + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + DBFlush(true); + SetCurrentDir(cwd); + RemoveTree(tmp); + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + +static void ClearWalletRuntimeForTest() +{ + CRITICAL_BLOCK(cs_mapKeys) + { + mapKeys.clear(); + mapCryptedKeys.clear(); + mapPubKeys.clear(); + mapMasterKeys.clear(); + vWalletMasterKey.clear(); + vchCryptedHDMaster.clear(); + vchCryptedHDChainCode.clear(); + fWalletEncrypted = false; + fWalletLocked = true; + } + CRITICAL_BLOCK(cs_keyPool) + mapKeyPool.clear(); + vchHDMaster.clear(); + vchHDChainCode.clear(); + nHDNext = 0; +} + +static int RunWalletCryptoSelfTest() +{ + fflush(stdout); + printf("wallet-crypto self-test\n"); + + std::string tmp; + std::string cwd; + if (!MakeTempDir(tmp)) + { + printf(" FAIL could not create a temporary data directory\n"); + return 1; + } + if (!GetCurrentDir(cwd) || !SetCurrentDir(tmp)) + { + printf(" FAIL could not move into the temporary data directory\n"); + RemoveTree(tmp); + return 1; + } + + int nFail = 0; + strSetDataDir = tmp; + printf(" temp datadir: %s\n", tmp.c_str()); + + try + { + const string strPassphrase = "btf-test-passphrase"; + const string strWrongPassphrase = "btf-test-passphraser"; + + CKey key; + key.MakeNewKey(); + vector vchPubKey = key.GetPubKey(); + CPrivKey vchPrivKey = key.GetPrivKey(); + + CKeyingMaterial vchMasterKey; + for (int i = 0; i < 32; i++) + vchMasterKey.push_back((unsigned char)(i + 1)); + + CWalletMasterKey kMasterKey; + kMasterKey.vchSalt.clear(); + for (int i = 0; i < 8; i++) + kMasterKey.vchSalt.push_back((unsigned char)(0xa0 + i)); + kMasterKey.nDeriveIterations = 2500; + + CKeyingMaterial vchPassKey; + vector vchPassIV; + nFail += Check(DeriveWalletPassphraseKey(strPassphrase, kMasterKey.vchSalt, + kMasterKey.nDeriveIterations, + vchPassKey, vchPassIV), + "a passphrase key can be derived") ? 0 : 1; + nFail += Check(EncryptSecret(vchPassKey, + vector(vchMasterKey.begin(), vchMasterKey.end()), + vchPassIV, kMasterKey.vchCryptedKey), + "the wallet master key can be encrypted") ? 0 : 1; + + vector vchCryptedKey; + nFail += Check(EncryptSecret(vchMasterKey, + vector(vchPrivKey.begin(), vchPrivKey.end()), + WalletKeyIV(vchPubKey), vchCryptedKey), + "a private key can be encrypted under the wallet master key") ? 0 : 1; + + { + CWalletDB walletdb("cr"); + nFail += Check(walletdb.WriteWalletMinVersion(WALLET_FORMAT_ENCRYPTED), + "an encrypted wallet format marker can be written") ? 0 : 1; + nFail += Check(walletdb.WriteMasterKey(0, kMasterKey), + "the encrypted master key can be written") ? 0 : 1; + nFail += Check(walletdb.WriteCryptedKey(vchPubKey, vchCryptedKey), + "the encrypted private key can be written") ? 0 : 1; + nFail += Check(walletdb.WriteDefaultKey(vchPubKey), + "the encrypted wallet default key can be written") ? 0 : 1; + } + + ClearWalletRuntimeForTest(); + + vector vchDefaultKey; + nFail += Check(CWalletDB("r").LoadWallet(vchDefaultKey), + "an encrypted wallet can be loaded") ? 0 : 1; + nFail += Check(vchDefaultKey == vchPubKey, + "the encrypted wallet default public key loads") ? 0 : 1; + nFail += Check(IsWalletEncrypted() && IsWalletLocked(), + "an encrypted wallet loads locked") ? 0 : 1; + nFail += Check(WalletCanSpendKey(vchPubKey), + "an encrypted key is recognized as wallet-owned while locked") ? 0 : 1; + + CPrivKey vchDecrypted; + string strError; + nFail += Check(!GetWalletPrivKey(vchPubKey, vchDecrypted, strError), + "a locked encrypted key cannot be read") ? 0 : 1; + nFail += Check(!UnlockWallet(strWrongPassphrase, strError), + "the wrong-passphrase does not unlock") ? 0 : 1; + nFail += Check(UnlockWallet(strPassphrase, strError), + "the right passphrase unlocks") ? 0 : 1; + nFail += Check(!IsWalletLocked(), + "the wallet reports unlocked") ? 0 : 1; + nFail += Check(GetWalletPrivKey(vchPubKey, vchDecrypted, strError) && + vchDecrypted == vchPrivKey, + "an unlocked encrypted key decrypts to the original private key") ? 0 : 1; + LockWallet(); + nFail += Check(IsWalletLocked(), + "locking clears the unlocked state") ? 0 : 1; + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + DBFlush(true); + SetCurrentDir(cwd); + RemoveTree(tmp); + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + +static bool FileContainsBytes(const string& strPath, const vector& vchNeedle) +{ + if (vchNeedle.empty()) + return false; + FILE* pf = fopen(strPath.c_str(), "rb"); + if (!pf) + return false; + + vector vchHaystack; + unsigned char buf[4096]; + size_t n = 0; + while ((n = fread(buf, 1, sizeof(buf), pf)) > 0) + vchHaystack.insert(vchHaystack.end(), buf, buf + n); + fclose(pf); + + if (vchHaystack.size() < vchNeedle.size()) + return false; + return search(vchHaystack.begin(), vchHaystack.end(), + vchNeedle.begin(), vchNeedle.end()) != vchHaystack.end(); +} + +static bool FileContainsText(const string& strPath, const string& strNeedle) +{ + FILE* pf = fopen(strPath.c_str(), "rb"); + if (!pf) + return false; + + string strHaystack; + char buf[4096]; + size_t n = 0; + while ((n = fread(buf, 1, sizeof(buf), pf)) > 0) + strHaystack.append(buf, buf + n); + fclose(pf); + + return strHaystack.find(strNeedle) != string::npos; +} + +static bool WriteTextFile(const string& strPath, const string& strText) +{ + FILE* pf = fopen(strPath.c_str(), "wb"); + if (!pf) + return false; + bool fOk = fwrite(strText.data(), 1, strText.size(), pf) == strText.size(); + if (fclose(pf) != 0) + fOk = false; + return fOk; +} + +static string QuoteCommandArg(const string& str) +{ + string out = "\""; + for (size_t i = 0; i < str.size(); i++) + { + if (str[i] == '"') + out += "\\\""; + else + out += str[i]; + } + out += "\""; + return out; +} + +static int RunBitflashChild(const string& strExe, + const vector& vArgs, + const string* pStdinFile = NULL) +{ +#ifdef _WIN32 + vector argv; + argv.push_back((char*)strExe.c_str()); + for (size_t i = 0; i < vArgs.size(); i++) + argv.push_back((char*)vArgs[i].c_str()); + argv.push_back(NULL); + + int nOldOut = _dup(1); + int nOldErr = _dup(2); + int nOldIn = _dup(0); + int nNull = _open("NUL", _O_WRONLY); + int nIn = -1; + if (nNull >= 0) + { + _dup2(nNull, 1); + _dup2(nNull, 2); + } + if (pStdinFile) + { + nIn = _open(pStdinFile->c_str(), _O_RDONLY); + if (nIn >= 0) + _dup2(nIn, 0); + } + int nRet = _spawnv(_P_WAIT, strExe.c_str(), &argv[0]); + if (nIn >= 0) + _close(nIn); + if (nNull >= 0) + _close(nNull); + if (nOldIn >= 0) + { + _dup2(nOldIn, 0); + _close(nOldIn); + } + if (nOldOut >= 0) + { + _dup2(nOldOut, 1); + _close(nOldOut); + } + if (nOldErr >= 0) + { + _dup2(nOldErr, 2); + _close(nOldErr); + } + return nRet; +#else + string strCmd = QuoteCommandArg(strExe); + for (size_t i = 0; i < vArgs.size(); i++) + strCmd += " " + QuoteCommandArg(vArgs[i]); + if (pStdinFile) + strCmd += " < " + QuoteCommandArg(*pStdinFile); + strCmd += " > /dev/null 2>&1"; + return system(strCmd.c_str()); +#endif +} + +static int RunWalletEncryptSelfTest() +{ + fflush(stdout); + printf("wallet-encrypt self-test\n"); + + std::string tmp; + std::string cwd; + if (!MakeTempDir(tmp)) + { + printf(" FAIL could not create a temporary data directory\n"); + return 1; + } + if (!GetCurrentDir(cwd) || !SetCurrentDir(tmp)) + { + printf(" FAIL could not move into the temporary data directory\n"); + RemoveTree(tmp); + return 1; + } + + int nFail = 0; + bool fWalletEnvClosed = false; + strSetDataDir = tmp; + printf(" temp datadir: %s\n", tmp.c_str()); + + try + { + if (!LoadWallet()) + throw std::runtime_error("LoadWallet failed"); + + string strError; + const string strMnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + nFail += Check(SetHDSeedFromMnemonic(strMnemonic, strError), + "a phrase can be installed before encryption") ? 0 : 1; + TopUpKeyPool(); + + vector vchDefaultPubKey = keyUser.GetPubKey(); + CPrivKey vchDefaultPrivKey; + nFail += Check(GetWalletPrivKey(vchDefaultPubKey, vchDefaultPrivKey, strError), + "the default private key is readable before encryption") ? 0 : 1; + vector vchDefaultPrivBytes(vchDefaultPrivKey.begin(), vchDefaultPrivKey.end()); + vector vchPlainHDMaster = vchHDMaster; + vector vchPlainHDChainCode = vchHDChainCode; + + string strBackup; + nFail += Check(EncryptWallet("btf-test-passphrase", strBackup, strError), + "wallet.dat can be rewritten encrypted") ? 0 : 1; + fWalletEnvClosed = true; + nFail += Check(FileExists(strBackup.c_str()), + "the original wallet is preserved as an explicit backup") ? 0 : 1; + + string strWalletPath = GetAppDir() + "/wallet.dat"; + nFail += Check(!FileContainsBytes(strWalletPath, vchDefaultPrivBytes), + "the active wallet no longer contains the default private key bytes") ? 0 : 1; + nFail += Check(!FileContainsBytes(strWalletPath, vchPlainHDMaster), + "the active wallet no longer contains the HD master key bytes") ? 0 : 1; + nFail += Check(!FileContainsBytes(strWalletPath, vchPlainHDChainCode), + "the active wallet no longer contains the HD chain-code bytes") ? 0 : 1; + +#ifdef _WIN32 + string strExe = cwd + "\\bitflash.exe"; +#else + string strExe = cwd + "/bitflash-node"; +#endif + string strWrongDump = tmp + "/wrong-pass-dump.txt"; + string strRightDump = tmp + "/right-pass-dump.txt"; + string strLiteralDump = tmp + "/literal-pass-dump.txt"; + string strStdinDump = tmp + "/stdin-pass-dump.txt"; + string strWrongPassFile = tmp + "/wrong-pass.txt"; + string strRightPassFile = tmp + "/right-pass.txt"; + nFail += Check(WriteTextFile(strWrongPassFile, "wrong-passphrase\n") && + WriteTextFile(strRightPassFile, "btf-test-passphrase\n"), + "passphrase files can be written for restarted commands") ? 0 : 1; + + vector vWrongArgs; + vWrongArgs.push_back("-datadir=" + tmp); + vWrongArgs.push_back("-walletpassphrase=@" + strWrongPassFile); + vWrongArgs.push_back("-dumpwallet=" + strWrongDump); + vWrongArgs.push_back("-nogui"); + int nWrongRet = RunBitflashChild(strExe, vWrongArgs); + nFail += Check(nWrongRet != 0 && !FileExists(strWrongDump.c_str()), + "a restarted wallet rejects the wrong-passphrase") ? 0 : 1; + + vector vLiteralArgs; + vLiteralArgs.push_back("-datadir=" + tmp); + vLiteralArgs.push_back("-walletpassphrase=btf-test-passphrase"); + vLiteralArgs.push_back("-dumpwallet=" + strLiteralDump); + vLiteralArgs.push_back("-nogui"); + int nLiteralRet = RunBitflashChild(strExe, vLiteralArgs); + nFail += Check(nLiteralRet != 0 && !FileExists(strLiteralDump.c_str()), + "literal passphrases on the command line are refused") ? 0 : 1; + + vector vRightArgs; + vRightArgs.push_back("-datadir=" + tmp); + vRightArgs.push_back("-walletpassphrase=@" + strRightPassFile); + vRightArgs.push_back("-dumpwallet=" + strRightDump); + vRightArgs.push_back("-nogui"); + int nRightRet = RunBitflashChild(strExe, vRightArgs); + nFail += Check(nRightRet == 0 && FileExists(strRightDump.c_str()), + "a restarted wallet unlocks with the right passphrase") ? 0 : 1; + nFail += Check(FileContainsText(strRightDump, HexStrLocal(vchDefaultPrivBytes)), + "the restarted wallet can decrypt and dump the original key") ? 0 : 1; + + vector vStdinArgs; + vStdinArgs.push_back("-datadir=" + tmp); + vStdinArgs.push_back("-walletpassphrase"); + vStdinArgs.push_back("-dumpwallet=" + strStdinDump); + vStdinArgs.push_back("-nogui"); + int nStdinRet = RunBitflashChild(strExe, vStdinArgs, &strRightPassFile); + nFail += Check(nStdinRet == 0 && FileExists(strStdinDump.c_str()), + "a restarted wallet can read the passphrase from stdin") ? 0 : 1; + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + if (!fWalletEnvClosed) + DBFlush(true); + SetCurrentDir(cwd); + RemoveTree(tmp); + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + static int RunNetMessageSelfTest() { fflush(stdout); @@ -728,12 +1224,18 @@ int RunSelfTest(const std::string& name) return RunWalletKeyPoolSelfTest(); if (name == "wallet-hd") return RunWalletHDSelfTest(); + if (name == "wallet-format") + return RunWalletFormatSelfTest(); + if (name == "wallet-crypto") + return RunWalletCryptoSelfTest(); + if (name == "wallet-encrypt") + return RunWalletEncryptSelfTest(); if (name == "net-message") return RunNetMessageSelfTest(); if (name == "consensus-limits") return RunConsensusLimitsSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, net-message, consensus-limits\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits\n"); return 1; } diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index b449df9..06f35a5 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -406,6 +406,36 @@ int CmdRecoveryAudit() return 0; } +int CmdEncryptWallet(const std::string& strPassphrase) +{ + AttachTerminal(); + + string strBackup; + string strError; + if (!EncryptWallet(strPassphrase, strBackup, strError)) + { + fprintf(stderr, "Cannot encrypt wallet: %s\n", strError.c_str()); + fprintf(stderr, "Nothing was changed.\n"); + return 1; + } + + printf("Wallet encrypted.\n"); + printf("The old unencrypted wallet was moved to:\n"); + printf(" %s\n", strBackup.c_str()); + printf("That backup still contains private keys in plain text. Move it offline or\n"); + printf("delete it after you have a safer backup plan.\n"); + printf("Restart Bitflash before using the wallet again.\n"); + fflush(stdout); + + // The rewritten wallet.dat is clean; the environment's write-ahead logs are + // not, because every plaintext record this wallet ever wrote passed through + // them. Closing the environment and dropping the logs is the last step of + // encrypting, not an optimisation -- our own backup advice is to copy the + // whole directory, and following it otherwise carries the keys along. + PurgeDbEnvironmentLogs(); + return 0; +} + // Spend, from the command line. // // SendMoney() has been in this tree since 0.1.0 and only the window ever called @@ -421,6 +451,12 @@ int CmdSendTo(const std::string& strArg) { AttachTerminal(); + if (IsWalletLocked()) + { + fprintf(stderr, "Wallet is encrypted and locked. Start with /walletpassphrase or /walletpassphrase=@FILE to spend.\n"); + return 1; + } + std::string::size_type comma = strArg.rfind(','); if (comma == std::string::npos) { diff --git a/src/walletcmd.h b/src/walletcmd.h index 32d3eef..04d3fb7 100644 --- a/src/walletcmd.h +++ b/src/walletcmd.h @@ -53,6 +53,10 @@ int CmdShowDerived(int nCount); // by the installed recovery phrase or still depends on wallet.dat-only keys. int CmdRecoveryAudit(); +// Rewrite wallet.dat so private keys and the HD seed are encrypted with the +// given passphrase. Returns 0 on success and exits without starting the node. +int CmdEncryptWallet(const std::string& strPassphrase); + // Spend, from the command line. Until now the only way to send was the window, // so a headless node could be paid and could never pay -- it could hold a // balance it had no way to move. strArg is "ADDRESS,AMOUNT". -- cgit v1.2.3 From 8c2e375600c0eb9bedb8f85ee4fa860825f23521 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 7 Aug 2026 11:40:46 -0300 Subject: test: cover pool Stratum share accounting --- src/Makefile | 1 + src/makefile.mingw | 1 + src/rpc.cpp | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/selftest.cpp | 6 +++- 4 files changed, 92 insertions(+), 1 deletion(-) (limited to 'src/selftest.cpp') diff --git a/src/Makefile b/src/Makefile index cc52fd6..bc15ef8 100644 --- a/src/Makefile +++ b/src/Makefile @@ -191,5 +191,6 @@ tests: test_bip32 bitflash-node ./bitflash-node -selftest=wallet-encrypt ./bitflash-node -selftest=net-message ./bitflash-node -selftest=consensus-limits + ./bitflash-node -selftest=pool-stratum .PHONY: tests fuzz-net-message-smoke fuzz-script-smoke diff --git a/src/makefile.mingw b/src/makefile.mingw index e076c49..b946e50 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -116,3 +116,4 @@ tests: test_bip32.exe bitflash.exe ./bitflash.exe -selftest=wallet-encrypt ./bitflash.exe -selftest=net-message ./bitflash.exe -selftest=consensus-limits + ./bitflash.exe -selftest=pool-stratum diff --git a/src/rpc.cpp b/src/rpc.cpp index 8f3740d..383769a 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -1180,6 +1180,91 @@ static bool HandleLine(Miner* m, const std::string& rawLine, return true; } +int RunPoolStratumSelfTest() +{ + int nFail = 0; + auto check = [&](bool ok, const char* msg) { + fprintf(stdout, " %s %s\n", ok ? "ok" : "FAIL", msg); + if (!ok) nFail++; + }; + + fprintf(stdout, "pool-stratum self-test\n"); + + StratumJob savedJob = gCurrentJob; + bool savedHaveJob = gHaveJob; + + CTransaction coinbase; + coinbase.vin.resize(1); + coinbase.vin[0].prevout.SetNull(); + coinbase.vin[0].scriptSig << 1 << (CBigNum)1; + coinbase.vout.resize(1); + coinbase.vout[0].nValue = 50 * COIN; + + CBlock block; + block.vtx.push_back(coinbase); + block.nVersion = 1; + block.nBits = 0x1f0fffff; + block.nTime = 1; + block.nNonce = 1; + block.hashMerkleRoot = block.BuildMerkleTree(); + + gCurrentJob.jobId = "selftest"; + gCurrentJob.block = block; + gCurrentJob.target = 0; // accepted share, not a block candidate + gCurrentJob.height = 1; + gHaveJob = true; + + Miner miner(INVALID_SOCKET); + miner.authorised = true; + miner.address = "selftest-address"; + miner.worker = "worker"; + miner.shareTarget = ~uint256(0); // every RandomX hash is a valid share + miner.difficulty = 0.001; + miner.vardiffWindowStart = GetTime(); + + std::map roundShareCount; + uint64 roundShareTotal = 0; + int blocksFound = 0; + + uint256 targetFromNotify; + CBlock minerBlock = BuildMinerBlock(gCurrentJob, miner.extranonce2); + json params = MakeNotifyParams(gCurrentJob, minerBlock, miner.shareTarget, true); + std::vector targetBytes = FromHex(params[3].get()); + if (targetBytes.size() == 32) + memcpy(&targetFromNotify, &targetBytes[0], 32); + check(targetFromNotify == miner.shareTarget, + "mining.notify exposes the same share target the pool validates"); + + json submit = { + {"id", 7}, + {"method", "mining.submit"}, + {"params", json::array({miner.address, "selftest", ToHex(&minerBlock.nNonce, 4)})} + }; + HandleLine(&miner, submit.dump(), roundShareCount, roundShareTotal, blocksFound); + + check(roundShareTotal == 1, "fake low-difficulty share increments the round total"); + check(roundShareCount[miner.address] == 1, "fake share is credited to the submitting address"); + check(miner.sessionShares == 1 && miner.roundShares == 1 && miner.totalShares == 1, + "miner share counters advance together"); + check(blocksFound == 0, "share below block target is not counted as a found block"); + + json stale = { + {"id", 8}, + {"method", "mining.submit"}, + {"params", json::array({miner.address, "stale", ToHex(&minerBlock.nNonce, 4)})} + }; + HandleLine(&miner, stale.dump(), roundShareCount, roundShareTotal, blocksFound); + check(roundShareTotal == 1, "stale submit does not increment shares"); + + gCurrentJob = savedJob; + gHaveJob = savedHaveJob; + + fprintf(stdout, "%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + // --------------------------------------------------------------------------- // Stats refresh -- called every 2s from event loop // --------------------------------------------------------------------------- diff --git a/src/selftest.cpp b/src/selftest.cpp index ff0289b..fadc959 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -10,6 +10,8 @@ #include "selftest.h" #include "walletcmd.h" +extern int RunPoolStratumSelfTest(); + // Test results go to the terminal, not to debug.log. // // printf in this tree is OutputDebugStringF, which writes into the data @@ -1234,8 +1236,10 @@ int RunSelfTest(const std::string& name) return RunNetMessageSelfTest(); if (name == "consensus-limits") return RunConsensusLimitsSelfTest(); + if (name == "pool-stratum") + return RunPoolStratumSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits, pool-stratum\n"); return 1; } -- cgit v1.2.3 From dfbf7a91caa4a85da7ce10fa296cfa6d2ead5d0c Mon Sep 17 00:00:00 2001 From: Bitflash Date: Tue, 11 Aug 2026 09:57:29 -0300 Subject: wallet: SLIP-0044 4346950 is official, drop 'provisional' naming The coin type 4346950 (0x425446 = BTF) was merged into the SLIP-0044 registry on 2026-08-07. Rename HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL to HD_BIP44_COIN_TYPE_BITFLASH and update comments, logs and UI strings. Value is unchanged, so derivation paths and addresses are identical. --- src/db.cpp | 6 +++--- src/gui.cpp | 2 +- src/main.cpp | 6 +++--- src/main.h | 8 ++++---- src/selftest.cpp | 12 ++++++------ src/walletcmd.cpp | 2 +- 6 files changed, 18 insertions(+), 18 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/db.cpp b/src/db.cpp index 244d5a7..9f62964 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -880,9 +880,9 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) } if (!fHaveStoredHDCoinType) { - nHDCoinType = HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL; + nHDCoinType = HD_BIP44_COIN_TYPE_BITFLASH; printf("LoadWallet: deterministic seed has no BIP44 coin type metadata; " - "using provisional Bitflash coin type %u\n", nHDCoinType); + "using Bitflash coin type %u\n", nHDCoinType); } } else @@ -891,7 +891,7 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) nHDNext = 0; nHDReceiveNext = 0; nHDChangeNext = 0; - nHDCoinType = HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL; + nHDCoinType = HD_BIP44_COIN_TYPE_BITFLASH; } // fGenerateBitcoins and nMineMode only mean anything together, and a diff --git a/src/gui.cpp b/src/gui.cpp index 8f1d204..8dcb854 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -1250,7 +1250,7 @@ static void DrawWalletSafetyDialog() if (g_recoveryAudit.fHaveSeed) { ImGui::Text("Derivation schema: %s", HDKeySchemaName(g_recoveryAudit.nSchema).c_str()); - ImGui::Text("BIP44 coin type: %u (provisional BITFLASH)", g_recoveryAudit.nCoinType); + ImGui::Text("BIP44 coin type: %u (SLIP-0044 BITFLASH)", g_recoveryAudit.nCoinType); } ImGui::Text("Phrase-backed spendable balance: %s BTF", FmtMoney(g_recoveryAudit.nRecoverableCredit).c_str()); diff --git a/src/main.cpp b/src/main.cpp index 2eb0e3a..f83164f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -642,7 +642,7 @@ unsigned int nHDNext = 0; int nHDKeySchema = HD_SCHEMA_NONE; unsigned int nHDReceiveNext = 0; unsigned int nHDChangeNext = 0; -unsigned int nHDCoinType = HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL; +unsigned int nHDCoinType = HD_BIP44_COIN_TYPE_BITFLASH; string HDKeySchemaName(int nSchema) { @@ -798,7 +798,7 @@ bool SetHDSeedFromMnemonic(const string& strMnemonic, string& strErrorRet) if (!CWalletDB().WriteHDMaster(master.privateKey, master.chainCode) || !CWalletDB().WriteHDSchema(HD_SCHEMA_BIP44) || - !CWalletDB().WriteHDCoinType(HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL) || + !CWalletDB().WriteHDCoinType(HD_BIP44_COIN_TYPE_BITFLASH) || !CWalletDB().WriteHDNext(0) || !CWalletDB().WriteHDReceiveNext(1) || !CWalletDB().WriteHDChangeNext(0)) @@ -811,7 +811,7 @@ bool SetHDSeedFromMnemonic(const string& strMnemonic, string& strErrorRet) nHDKeySchema = HD_SCHEMA_BIP44; nHDReceiveNext = 1; nHDChangeNext = 0; - nHDCoinType = HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL; + nHDCoinType = HD_BIP44_COIN_TYPE_BITFLASH; // Index 0 is spoken for before it is derived, so a failure below cannot // leave the pool free to hand it out as an ordinary key. The cost of diff --git a/src/main.h b/src/main.h index eafbf16..b389f1d 100644 --- a/src/main.h +++ b/src/main.h @@ -216,9 +216,9 @@ static const unsigned int HD_BIP44_PURPOSE = 44; static const unsigned int HD_BIP44_ACCOUNT = 0; static const unsigned int HD_BIP44_CHAIN_RECEIVE = 0; static const unsigned int HD_BIP44_CHAIN_CHANGE = 1; -// Provisional until Bitflash has an official SLIP-0044 assignment. -// Proposed registry row: 4346950 | BITFLASH | Bitflash. -static const unsigned int HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL = 4346950; +// Official SLIP-0044 assignment (registered 2026-08-07): +// 4346950 | BITFLASH | Bitflash (4346950 == 0x425446 == "BTF") +static const unsigned int HD_BIP44_COIN_TYPE_BITFLASH = 4346950; extern int nHDKeySchema; extern unsigned int nHDReceiveNext; // BIP44 external chain extern unsigned int nHDChangeNext; // BIP44 internal chain @@ -316,7 +316,7 @@ struct WalletRecoveryAudit nDerivedKnown = 0; nReceiveNext = 0; nChangeNext = 0; - nCoinType = HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL; + nCoinType = HD_BIP44_COIN_TYPE_BITFLASH; nRecoverableTx = 0; nLegacyTx = 0; nRecoverableImmatureTx = 0; diff --git a/src/selftest.cpp b/src/selftest.cpp index fadc959..69a3596 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -350,17 +350,17 @@ static int RunWalletHDSelfTest() nFail += Check(HaveHDSeed(), "the wallet reports having a seed") ? 0 : 1; nFail += Check(nHDKeySchema == HD_SCHEMA_BIP44, "a new recovery phrase records the BIP44 HD schema") ? 0 : 1; - nFail += Check(nHDCoinType == HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL, - "a new recovery phrase records the provisional BIP44 coin type") ? 0 : 1; + nFail += Check(nHDCoinType == HD_BIP44_COIN_TYPE_BITFLASH, + "a new recovery phrase records the SLIP-0044 BIP44 coin type") ? 0 : 1; nFail += Check(nHDReceiveNext == 1 && nHDChangeNext == 0, "BIP44 receive/change counters reserve the default receive key") ? 0 : 1; - std::vector vBIP44Path = HDBIP44Path(HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL, + std::vector vBIP44Path = HDBIP44Path(HD_BIP44_COIN_TYPE_BITFLASH, HD_BIP44_ACCOUNT, HD_BIP44_CHAIN_RECEIVE, 0); nFail += Check(vBIP44Path.size() == 5 && vBIP44Path[0] == (HD_BIP44_PURPOSE | bitflash::BIP32_HARDENED) && - vBIP44Path[1] == (HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL | bitflash::BIP32_HARDENED) && + vBIP44Path[1] == (HD_BIP44_COIN_TYPE_BITFLASH | bitflash::BIP32_HARDENED) && vBIP44Path[2] == (HD_BIP44_ACCOUNT | bitflash::BIP32_HARDENED) && vBIP44Path[3] == HD_BIP44_CHAIN_RECEIVE && vBIP44Path[4] == 0, @@ -382,7 +382,7 @@ static int RunWalletHDSelfTest() hdParent.chainCode = vchHDChainCode; bitflash::BIP32PrivateNode hdBIP44ReceiveChild; if (!bitflash::BIP32DerivePath(hdParent, - HDBIP44Path(HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL, + HDBIP44Path(HD_BIP44_COIN_TYPE_BITFLASH, HD_BIP44_ACCOUNT, HD_BIP44_CHAIN_RECEIVE, 0), @@ -570,7 +570,7 @@ static int RunWalletHDSelfTest() nFail += Check(audit.fHaveSeed, "the recovery audit reports the phrase") ? 0 : 1; nFail += Check(audit.nSchema == HD_SCHEMA_BIP44, "the recovery audit reports the derivation schema") ? 0 : 1; - nFail += Check(audit.nCoinType == HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL, + nFail += Check(audit.nCoinType == HD_BIP44_COIN_TYPE_BITFLASH, "the recovery audit reports the BIP44 coin type") ? 0 : 1; nFail += Check(audit.nReceiveNext == nHDReceiveNext && audit.nChangeNext == 1, "the recovery audit reports receive/change counters") ? 0 : 1; diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index 06f35a5..0ea2251 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -354,7 +354,7 @@ int CmdRecoveryAudit() if (audit.fHaveSeed) { printf(" derivation schema: %s\n", HDKeySchemaName(audit.nSchema).c_str()); - printf(" BIP44 coin type: %u (provisional BITFLASH)\n", audit.nCoinType); + printf(" BIP44 coin type: %u (SLIP-0044 BITFLASH)\n", audit.nCoinType); printf(" derived keys known to this wallet: %u\n", audit.nDerivedKnown); printf(" receive/change counters: %u/%u\n", audit.nReceiveNext, audit.nChangeNext); -- cgit v1.2.3 From faf6c4d24304b770c2d43515da04832b2124c92a Mon Sep 17 00:00:00 2001 From: Bitflash Date: Tue, 11 Aug 2026 10:13:04 -0300 Subject: money: ParseMoney accepts full 8 decimals (0.5, 0.00000001) The legacy parser only accepted two decimal places (cents) and rejected 0.5, leaving six of the eight coin decimals unreachable by -sendto and the send window. Parse up to 8 fractional digits Bitcoin-style, reject >8 to avoid silent truncation. Adds the parse-money selftest (12 cases). --- src/Makefile | 1 + src/selftest.cpp | 37 +++++++++++++++++++++++++++++++++++++ src/util.cpp | 23 +++++++++++++---------- 3 files changed, 51 insertions(+), 10 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/Makefile b/src/Makefile index bc15ef8..00a8182 100644 --- a/src/Makefile +++ b/src/Makefile @@ -192,5 +192,6 @@ tests: test_bip32 bitflash-node ./bitflash-node -selftest=net-message ./bitflash-node -selftest=consensus-limits ./bitflash-node -selftest=pool-stratum + ./bitflash-node -selftest=parse-money .PHONY: tests fuzz-net-message-smoke fuzz-script-smoke diff --git a/src/selftest.cpp b/src/selftest.cpp index 69a3596..dcb9192 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1218,6 +1218,41 @@ static int RunConsensusLimitsSelfTest() return nFail == 0 ? 0 : 1; } +static int RunParseMoneySelfTest() +{ + printf("parse-money self-test\n"); + int nFail = 0; + struct Case { const char* in; bool ok; int64 val; }; + Case cases[] = { + {"0.5", true, 50000000LL}, + {"1", true, 100000000LL}, + {"1.23456789", true, 123456789LL}, + {"0.00000001", true, 1LL}, + {"10.00000000", true, 1000000000LL}, + {"1,000", true, 100000000000LL}, + {"1.5 ", true, 150000000LL}, + {"0.123456789", false, 0LL}, + {"1.2.3", false, 0LL}, + {"abc", false, 0LL}, + {"-1", false, 0LL}, + {"1e5", false, 0LL}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) + { + int64 v = -1; + bool r = ParseMoney(cases[i].in, v); + bool pass = (r == cases[i].ok) && (!r || v == cases[i].val); + char desc[160]; + snprintf(desc, sizeof(desc), "ParseMoney(\"%s\") %s and value matches", + cases[i].in, cases[i].ok ? "accepts" : "rejects"); + nFail += Check(pass, desc) ? 0 : 1; + } + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + int RunSelfTest(const std::string& name) { AttachTerminal(); @@ -1238,6 +1273,8 @@ int RunSelfTest(const std::string& name) return RunConsensusLimitsSelfTest(); if (name == "pool-stratum") return RunPoolStratumSelfTest(); + if (name == "parse-money") + return RunParseMoneySelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits, pool-stratum\n"); diff --git a/src/util.cpp b/src/util.cpp index d52ca88..0cd0133 100644 --- a/src/util.cpp +++ b/src/util.cpp @@ -337,7 +337,7 @@ string FormatMoney(int64 n, bool fPlus) bool ParseMoney(const char* pszIn, int64& nRet) { string strWhole; - int64 nCents = 0; + int64 nUnits = 0; // fractional part, scaled to COIN (8 decimals) const char* p = pszIn; while (isspace(*p)) p++; @@ -348,12 +348,16 @@ bool ParseMoney(const char* pszIn, int64& nRet) if (*p == '.') { p++; - if (!isdigit(p[0]) || !isdigit(p[1])) + int64 nMult = COIN / 10; // weight of the first decimal digit + while (isdigit(*p) && nMult > 0) + { + nUnits += nMult * (*p - '0'); + nMult /= 10; + p++; + } + // reject more than 8 fractional digits rather than silently truncate + if (isdigit(*p)) return false; - nCents = atoi64(p); - if (nCents < 0 || nCents > 99) - return false; - p += 2; break; } if (isspace(*p)) @@ -365,13 +369,12 @@ bool ParseMoney(const char* pszIn, int64& nRet) for (; *p; p++) if (!isspace(*p)) return false; - if (strWhole.size() > 17) + if (strWhole.size() > 10) // 10 whole digits * COIN stays within int64 return false; int64 nWhole = atoi64(strWhole); - int64 nValue = nWhole * 100 + nCents; - if (nValue / 100 != nWhole) + int64 nValue = nWhole * COIN + nUnits; + if (nValue < 0 || (nValue - nUnits) / COIN != nWhole) return false; - nValue *= CENT; nRet = nValue; return true; } -- cgit v1.2.3 From ff3829b28fd2ddc85a9a3bb4424cd5b4719854ab Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Tue, 11 Aug 2026 15:38:31 -0300 Subject: net: add optional SOCKS5 outbound proxy --- README.md | 6 ++ src/Makefile | 7 +- src/btfrv.cpp | 9 ++ src/main_gui.cpp | 16 +++- src/makefile.mingw | 7 +- src/net.cpp | 4 + src/nostr.cpp | 50 +---------- src/proxy.cpp | 254 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/proxy.h | 31 +++++++ src/readme.txt | 3 + src/selftest.cpp | 42 ++++++++- 11 files changed, 374 insertions(+), 55 deletions(-) create mode 100644 src/proxy.cpp create mode 100644 src/proxy.h (limited to 'src/selftest.cpp') diff --git a/README.md b/README.md index c28b0f1..1b68ccd 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,7 @@ Other options worth knowing: ```bash -datadir=PATH # wallet and chain data elsewhere -port=N # P2P listen port, default 8433 +-socks=HOST:PORT # SOCKS5 for outbound Nostr and .btf rendezvous dials -debug # verbose log; without it debug.log is nearly silent -help # full list ``` @@ -318,6 +319,11 @@ Other options worth knowing: needed — the data directory takes an exclusive lock, so a second node pointed at the same one will refuse to start. +`-socks=127.0.0.1:9050` routes outbound Nostr discovery and `.btf` rendezvous +dials through a local SOCKS5 proxy such as Tor. When it is set, the node skips +plain HTTP external-IP probes instead of leaking a direct request outside the +proxy. + ### When something looks wrong **Menu > Diagnostics**, and the same report in `debug.log` every ten minutes: diff --git a/src/Makefile b/src/Makefile index 00a8182..511fe79 100644 --- a/src/Makefile +++ b/src/Makefile @@ -30,7 +30,7 @@ WARNFLAGS = -Wformat -Werror=format \ -Wno-narrowing -Wno-unused-result CXXFLAGS = -std=gnu++14 -O2 $(WARNFLAGS) -fpermissive -pthread \ - -DSECP256K1_STATIC \ + -DSECP256K1_STATIC -DBITFLASH_NODE_BUILD \ -I$(SECP_INC) -I$(RANDOMX_DIR)/src \ -I/usr/include/nlohmann \ -I$(IMGUI_DIR) @@ -53,7 +53,7 @@ LIBS = -L$(SECP_LIB) -lsecp256k1 \ HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ key.h bignum.h script.h db.h base58.h net.h nostr.h \ randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h \ - selftest.h walletcmd.h compat.h sockcount.h font_roboto.h + selftest.h walletcmd.h compat.h sockcount.h proxy.h font_roboto.h IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ obj/imgui_widgets.o obj/imgui_impl_glfw.o obj/imgui_impl_opengl3.o @@ -61,7 +61,7 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ - obj/selftest.o obj/bip32.o obj/walletcmd.o + obj/selftest.o obj/bip32.o obj/walletcmd.o obj/proxy.o GUI_OBJS = obj/gui.o obj/main_gui.o @@ -193,5 +193,6 @@ tests: test_bip32 bitflash-node ./bitflash-node -selftest=consensus-limits ./bitflash-node -selftest=pool-stratum ./bitflash-node -selftest=parse-money + ./bitflash-node -selftest=socks5-proxy .PHONY: tests fuzz-net-message-smoke fuzz-script-smoke diff --git a/src/btfrv.cpp b/src/btfrv.cpp index 6ac3280..d4f079f 100644 --- a/src/btfrv.cpp +++ b/src/btfrv.cpp @@ -5,6 +5,9 @@ // Windows/Winsock implementation (Layer 6 will #ifdef this for Linux/BSD sockets). #include "btfrv.h" +#ifdef BITFLASH_NODE_BUILD +#include "proxy.h" +#endif // Portable sockets: Winsock on Windows, BSD sockets on Linux/POSIX (for the // headless relay `bitflashd` that runs on the VPS). @@ -92,6 +95,11 @@ static bool WriteN(SOCKET s, const void* buf, int n) static SOCKET ConnectTo(const char* host, unsigned short port) { +#ifdef BITFLASH_NODE_BUILD + SOCKET s = BtfConnectSocket(host, port, SOCK_RV_DIAL, 10); + if (s == INVALID_SOCKET) + return INVALID_SOCKET; +#else struct addrinfo hints, *res = NULL; memset(&hints, 0, sizeof(hints)); hints.ai_family = AF_INET; @@ -119,6 +127,7 @@ static SOCKET ConnectTo(const char* host, unsigned short port) CLOSESOCK(s); freeaddrinfo(res); return INVALID_SOCKET; } freeaddrinfo(res); +#endif // Clear the timeout -- the socket is now a live tunnel and must not // time out when idle. recv() will block until data arrives or peer closes. diff --git a/src/main_gui.cpp b/src/main_gui.cpp index b036ac9..90a6050 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -1,6 +1,7 @@ // Bitflash entry point -- starts node threads then runs GUI (or headless). #include "headers_core.h" +#include "proxy.h" #include "selftest.h" #include "walletcmd.h" #include // hardware_concurrency, to sanity-check /genproclimit @@ -110,7 +111,8 @@ static void PrintUsage() printf(" /debug\n"); printf(" /gen\n"); printf(" /nogui or /daemon\n"); - printf(" /selftest=wallet-keypool, wallet-hd, wallet-format, wallet-crypto, or wallet-encrypt\n"); + printf(" /selftest=wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt,\n"); + printf(" net-message, consensus-limits, pool-stratum, parse-money, or socks5-proxy\n"); printf("\n"); printf("Mining mode:\n"); printf(" /operator\n"); @@ -135,6 +137,7 @@ static void PrintUsage() printf("\n"); printf("Network:\n"); printf(" /port=N (P2P listen port, default 8433)\n"); + printf(" /socks=HOST:PORT (SOCKS5 proxy for outbound Nostr and .btf relay dials)\n"); printf(" /btfseed=ADDRESS:ENCHEX (extra bootstrap peer, repeatable)\n"); printf("\n"); printf("Wallet:\n"); @@ -282,6 +285,17 @@ static void ParseStartupArguments(int argc, char* argv[]) if (!announceRelay.empty()) strBtfAnnounceRelay = announceRelay; + string socksProxy = argval2(argc, argv, "/socks", "-socks"); + if (!socksProxy.empty()) + { + string err; + if (!BtfSetSocks5Proxy(socksProxy, err)) + fprintf(stderr, "Ignoring /socks=%s: %s\n", socksProxy.c_str(), err.c_str()); + else + fprintf(stderr, "SOCKS5 proxy enabled for outbound discovery/relay dials: %s\n", + BtfSocks5ProxyName().c_str()); + } + // /btfseed=ADDRESS:ENCHEX -- extra bootstrap peers, repeatable. Useful for // testing the seed path and for private networks that ship no compiled list. for (int i = 1; i < argc; i++) diff --git a/src/makefile.mingw b/src/makefile.mingw index b946e50..28e925c 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -21,7 +21,7 @@ WARNFLAGS = -Wformat -Werror=format \ # 64, which capped this node at 63 watchable peers no matter how many it had. CXXFLAGS = -std=gnu++14 -O2 $(WARNFLAGS) -fpermissive -pthread \ -DWIN32 -DNOPCH -DFD_SETSIZE=1024 \ - -DSECP256K1_STATIC \ + -DSECP256K1_STATIC -DBITFLASH_NODE_BUILD \ -I$(DEPS)/include -I$(RANDOMX)/src \ -I$(DEPS)/include/nlohmann \ -IC:/msys64/ucrt64/include \ @@ -42,7 +42,7 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ - obj/selftest.o obj/bip32.o obj/walletcmd.o + obj/selftest.o obj/bip32.o obj/walletcmd.o obj/proxy.o GUI_OBJS = obj/gui.o obj/main_gui.o obj/bitflash_res.o @@ -56,7 +56,7 @@ GUI_OBJS = obj/gui.o obj/main_gui.o obj/bitflash_res.o HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ key.h bignum.h script.h db.h base58.h net.h nostr.h \ randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h \ - selftest.h walletcmd.h sockcount.h compat.h font_roboto.h + selftest.h walletcmd.h sockcount.h proxy.h compat.h font_roboto.h all: bitflash.exe @@ -117,3 +117,4 @@ tests: test_bip32.exe bitflash.exe ./bitflash.exe -selftest=net-message ./bitflash.exe -selftest=consensus-limits ./bitflash.exe -selftest=pool-stratum + ./bitflash.exe -selftest=socks5-proxy diff --git a/src/net.cpp b/src/net.cpp index b9bb95d..fe9964e 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -17,6 +17,7 @@ #include #include "btfaddr.h" #include "btftunnel.h" +#include "proxy.h" void ThreadReconnectCachedBtfPeers(void* parg); void ThreadMessageHandler2(void* parg); @@ -361,6 +362,9 @@ string GetDiagnosticsText() bool GetMyExternalIP(unsigned int& ipRet) { + if (BtfSocks5ProxyEnabled()) + return error("GetMyExternalIP() skipped while SOCKS5 proxy is enabled\n"); + // Try several plain-text IP echo services in order. // Each returns just the IPv4 address as the first line of the HTTP body. struct { const char* host; const char* path; } services[] = { diff --git a/src/nostr.cpp b/src/nostr.cpp index 9bc538f..d286767 100644 --- a/src/nostr.cpp +++ b/src/nostr.cpp @@ -29,6 +29,7 @@ #include "btfaddr.h" #include "btfchan.h" +#include "proxy.h" using json = nlohmann::json; @@ -540,53 +541,8 @@ public: size_t colon = host.find(':'); if (colon != string::npos) { port = atoi(host.substr(colon + 1).c_str()); host = host.substr(0, colon); } - // Resolve and connect (TCP). AF_UNSPEC so we get both A and AAAA - // records back and try them in the order the resolver prefers, - // rather than hard-failing whenever the IPv4 path to a relay is - // filtered/rate-limited (increasingly common for CDN-fronted - // relays) while a working IPv6 route exists right there in the - // same getaddrinfo() result. - struct addrinfo hints, *res = NULL, *rp = NULL; - memset(&hints, 0, sizeof(hints)); - hints.ai_family = AF_UNSPEC; - hints.ai_socktype = SOCK_STREAM; - char portstr[16]; sprintf(portstr, "%d", port); - if (getaddrinfo(host.c_str(), portstr, &hints, &res) != 0 || !res) - return error("Nostr: getaddrinfo %s failed", host.c_str()); - - bool fConnected = false; - for (rp = res; rp != NULL; rp = rp->ai_next) - { - hSocket = BtfSocketTag(socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol), SOCK_NOSTR); - if (hSocket == INVALID_SOCKET) - continue; - - // Socket receive/send timeout: Windows takes a DWORD of - // milliseconds; POSIX takes a struct timeval. -#ifdef _WIN32 - DWORD tv = timeoutSec * 1000; - setsockopt(hSocket, SOL_SOCKET, SO_RCVTIMEO, (char*)&tv, sizeof(tv)); - setsockopt(hSocket, SOL_SOCKET, SO_SNDTIMEO, (char*)&tv, sizeof(tv)); -#else - struct timeval tv; - tv.tv_sec = timeoutSec; - tv.tv_usec = 0; - setsockopt(hSocket, SOL_SOCKET, SO_RCVTIMEO, (char*)&tv, sizeof(tv)); - setsockopt(hSocket, SOL_SOCKET, SO_SNDTIMEO, (char*)&tv, sizeof(tv)); -#endif - - if (connect(hSocket, rp->ai_addr, (int)rp->ai_addrlen) == 0) - { - fConnected = true; - break; - } - - BtfCloseSocket(hSocket); - hSocket = INVALID_SOCKET; - } - freeaddrinfo(res); - - if (!fConnected) + hSocket = BtfConnectSocket(host, (unsigned short)port, SOCK_NOSTR, timeoutSec); + if (hSocket == INVALID_SOCKET) return error("Nostr: connect %s:%d failed", host.c_str(), port); // TLS diff --git a/src/proxy.cpp b/src/proxy.cpp new file mode 100644 index 0000000..1e62ff9 --- /dev/null +++ b/src/proxy.cpp @@ -0,0 +1,254 @@ +// Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. + +#include "headers_core.h" +#include "proxy.h" +#include "sockcount.h" + +static CCriticalSection cs_socks5; +static bool g_fSocks5Proxy = false; +static std::string g_socks5Host; +static unsigned short g_socks5Port = 0; + +static void SetSocketTimeout(SOCKET s, int nTimeoutSecs) +{ + if (nTimeoutSecs <= 0) + return; +#ifdef _WIN32 + DWORD tv = (DWORD)nTimeoutSecs * 1000; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tv, sizeof(tv)); + setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (const char*)&tv, sizeof(tv)); +#else + struct timeval tv; + tv.tv_sec = nTimeoutSecs; + tv.tv_usec = 0; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); +#endif +} + +static bool ReadN(SOCKET s, void* buf, int n) +{ + char* p = (char*)buf; + int off = 0; + while (off < n) + { + int r = recv(s, p + off, n - off, 0); + if (r <= 0) + return false; + off += r; + } + return true; +} + +static bool WriteN(SOCKET s, const void* buf, int n) +{ + const char* p = (const char*)buf; + int off = 0; + while (off < n) + { + int r = send(s, p + off, n - off, 0); + if (r <= 0) + return false; + off += r; + } + return true; +} + +static bool ValidProxyHostChar(char c) +{ + return isalnum((unsigned char)c) || c == '.' || c == '-' || c == '_'; +} + +bool BtfParseSocks5Proxy(const std::string& spec, std::string& hostOut, + unsigned short& portOut, std::string& errOut) +{ + hostOut.clear(); + portOut = 0; + errOut.clear(); + + size_t colon = spec.rfind(':'); + if (colon == std::string::npos || colon == 0 || colon + 1 >= spec.size()) + { + errOut = "expected HOST:PORT"; + return false; + } + + std::string host = spec.substr(0, colon); + std::string port = spec.substr(colon + 1); + if (host.size() > 255) + { + errOut = "host is too long"; + return false; + } + for (size_t i = 0; i < host.size(); i++) + { + if (!ValidProxyHostChar(host[i])) + { + errOut = "host contains unsupported characters"; + return false; + } + } + for (size_t i = 0; i < port.size(); i++) + { + if (!isdigit((unsigned char)port[i])) + { + errOut = "port is not numeric"; + return false; + } + } + int nPort = atoi(port.c_str()); + if (nPort <= 0 || nPort > 65535) + { + errOut = "port is out of range"; + return false; + } + + hostOut = host; + portOut = (unsigned short)nPort; + return true; +} + +bool BtfSetSocks5Proxy(const std::string& spec, std::string& errOut) +{ + std::string host; + unsigned short port = 0; + if (!BtfParseSocks5Proxy(spec, host, port, errOut)) + return false; + CRITICAL_BLOCK(cs_socks5) + { + g_socks5Host = host; + g_socks5Port = port; + g_fSocks5Proxy = true; + } + return true; +} + +void BtfClearSocks5Proxy() +{ + CRITICAL_BLOCK(cs_socks5) + { + g_fSocks5Proxy = false; + g_socks5Host.clear(); + g_socks5Port = 0; + } +} + +bool BtfSocks5ProxyEnabled() +{ + CRITICAL_BLOCK(cs_socks5) + return g_fSocks5Proxy; + return false; +} + +std::string BtfSocks5ProxyName() +{ + CRITICAL_BLOCK(cs_socks5) + { + if (!g_fSocks5Proxy) + return ""; + return strprintf("%s:%u", g_socks5Host.c_str(), (unsigned)g_socks5Port); + } + return ""; +} + +static SOCKET ConnectDirect(const std::string& host, unsigned short port, + int nSockSite, int nTimeoutSecs) +{ + struct addrinfo hints, *res = NULL, *rp = NULL; + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + char portstr[16]; + sprintf(portstr, "%u", (unsigned)port); + if (getaddrinfo(host.c_str(), portstr, &hints, &res) != 0 || !res) + return INVALID_SOCKET; + + SOCKET s = INVALID_SOCKET; + for (rp = res; rp != NULL; rp = rp->ai_next) + { + s = BtfSocketTag(socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol), nSockSite); + if (s == INVALID_SOCKET) + continue; + SetSocketTimeout(s, nTimeoutSecs); + if (connect(s, rp->ai_addr, (int)rp->ai_addrlen) == 0) + break; + BtfCloseSocket(s); + s = INVALID_SOCKET; + } + freeaddrinfo(res); + return s; +} + +static bool Socks5Connect(SOCKET s, const std::string& destHost, + unsigned short destPort) +{ + if (destHost.empty() || destHost.size() > 255) + return false; + + unsigned char hello[3] = { 0x05, 0x01, 0x00 }; // SOCKS5, one method, no auth + if (!WriteN(s, hello, sizeof(hello))) + return false; + unsigned char choice[2] = { 0, 0 }; + if (!ReadN(s, choice, sizeof(choice)) || choice[0] != 0x05 || choice[1] != 0x00) + return false; + + std::vector req; + req.reserve(7 + destHost.size()); + req.push_back(0x05); // version + req.push_back(0x01); // CONNECT + req.push_back(0x00); // reserved + req.push_back(0x03); // domain name; proxy resolves, avoiding local DNS leak + req.push_back((unsigned char)destHost.size()); + req.insert(req.end(), destHost.begin(), destHost.end()); + req.push_back((unsigned char)(destPort >> 8)); + req.push_back((unsigned char)(destPort & 0xff)); + if (!WriteN(s, &req[0], (int)req.size())) + return false; + + unsigned char rep[4] = {0,0,0,0}; + if (!ReadN(s, rep, sizeof(rep)) || rep[0] != 0x05 || rep[1] != 0x00) + return false; + int nAddr = 0; + if (rep[3] == 0x01) nAddr = 4; + else if (rep[3] == 0x04) nAddr = 16; + else if (rep[3] == 0x03) + { + unsigned char len = 0; + if (!ReadN(s, &len, 1)) + return false; + nAddr = len; + } + else + return false; + + std::vector discard(nAddr + 2); + return ReadN(s, &discard[0], (int)discard.size()); +} + +SOCKET BtfConnectSocket(const std::string& destHost, unsigned short destPort, + int nSockSite, int nTimeoutSecs) +{ + std::string proxyHost; + unsigned short proxyPort = 0; + bool fProxy = false; + CRITICAL_BLOCK(cs_socks5) + { + fProxy = g_fSocks5Proxy; + proxyHost = g_socks5Host; + proxyPort = g_socks5Port; + } + + if (!fProxy) + return ConnectDirect(destHost, destPort, nSockSite, nTimeoutSecs); + + SOCKET s = ConnectDirect(proxyHost, proxyPort, nSockSite, nTimeoutSecs); + if (s == INVALID_SOCKET) + return INVALID_SOCKET; + if (!Socks5Connect(s, destHost, destPort)) + { + BtfCloseSocket(s); + return INVALID_SOCKET; + } + return s; +} diff --git a/src/proxy.h b/src/proxy.h new file mode 100644 index 0000000..b5e69c8 --- /dev/null +++ b/src/proxy.h @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. +// +// Optional SOCKS5 outbound proxy support. This is transport plumbing only: +// no consensus, wallet, mining, or .btf identity data depends on it. + +#ifndef BITFLASH_PROXY_H +#define BITFLASH_PROXY_H + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN 1 +#endif +#include +#else +#include "compat.h" +#endif + +#include + +bool BtfParseSocks5Proxy(const std::string& spec, std::string& hostOut, + unsigned short& portOut, std::string& errOut); +bool BtfSetSocks5Proxy(const std::string& spec, std::string& errOut); +void BtfClearSocks5Proxy(); +bool BtfSocks5ProxyEnabled(); +std::string BtfSocks5ProxyName(); + +SOCKET BtfConnectSocket(const std::string& destHost, unsigned short destPort, + int nSockSite, int nTimeoutSecs); + +#endif diff --git a/src/readme.txt b/src/readme.txt index b8b89c1..96f32c3 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -42,6 +42,9 @@ The resulting executable is src/bitflash.exe. Command-line options (in addition to the original ones) ------------------------------------------------------- /port=N listen on P2P port N (default 8433) + /socks=HOST:PORT + route outbound Nostr and .btf rendezvous dials through a + SOCKS5 proxy, e.g. /socks=127.0.0.1:9050 for local Tor /solomine mine without requiring a connected peer (local testing) /operator operator mode (run pool server) /participant=ADDR diff --git a/src/selftest.cpp b/src/selftest.cpp index dcb9192..5b36b9d 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -7,6 +7,7 @@ #include "headers_core.h" #include "bip32.h" +#include "proxy.h" #include "selftest.h" #include "walletcmd.h" @@ -1253,6 +1254,43 @@ static int RunParseMoneySelfTest() return nFail == 0 ? 0 : 1; } +static int RunSocks5ProxySelfTest() +{ + printf("socks5-proxy self-test\n"); + int nFail = 0; + std::string host, err; + unsigned short port = 0; + + nFail += Check(BtfParseSocks5Proxy("127.0.0.1:9050", host, port, err) && + host == "127.0.0.1" && port == 9050, + "parses local Tor proxy endpoint") ? 0 : 1; + nFail += Check(BtfParseSocks5Proxy("localhost:9050", host, port, err) && + host == "localhost" && port == 9050, + "parses hostname proxy endpoint") ? 0 : 1; + nFail += Check(!BtfParseSocks5Proxy("localhost", host, port, err), + "rejects missing port") ? 0 : 1; + nFail += Check(!BtfParseSocks5Proxy("localhost:0", host, port, err), + "rejects zero port") ? 0 : 1; + nFail += Check(!BtfParseSocks5Proxy("localhost:70000", host, port, err), + "rejects out-of-range port") ? 0 : 1; + nFail += Check(!BtfParseSocks5Proxy("user:pass@localhost:9050", host, port, err), + "rejects unsupported authenticated proxy syntax") ? 0 : 1; + + BtfClearSocks5Proxy(); + nFail += Check(!BtfSocks5ProxyEnabled(), "starts disabled after clear") ? 0 : 1; + nFail += Check(BtfSetSocks5Proxy("127.0.0.1:9050", err) && + BtfSocks5ProxyEnabled() && + BtfSocks5ProxyName() == "127.0.0.1:9050", + "enables parsed SOCKS5 proxy") ? 0 : 1; + BtfClearSocks5Proxy(); + nFail += Check(!BtfSocks5ProxyEnabled(), "clear disables proxy") ? 0 : 1; + + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + int RunSelfTest(const std::string& name) { AttachTerminal(); @@ -1275,8 +1313,10 @@ int RunSelfTest(const std::string& name) return RunPoolStratumSelfTest(); if (name == "parse-money") return RunParseMoneySelfTest(); + if (name == "socks5-proxy") + return RunSocks5ProxySelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits, pool-stratum\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy\n"); return 1; } -- cgit v1.2.3 From 066379ebcb1dac720221fe1a1841fa6f59df21d9 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Tue, 11 Aug 2026 15:51:52 -0300 Subject: net: harden SOCKS5 proxy parsing and tests --- README.md | 2 +- src/proxy.cpp | 115 ++++++++++++++++++++++++------ src/readme.txt | 1 + src/selftest.cpp | 212 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 309 insertions(+), 21 deletions(-) (limited to 'src/selftest.cpp') diff --git a/README.md b/README.md index 1b68ccd..353da25 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,7 @@ the same one will refuse to start. `-socks=127.0.0.1:9050` routes outbound Nostr discovery and `.btf` rendezvous dials through a local SOCKS5 proxy such as Tor. When it is set, the node skips plain HTTP external-IP probes instead of leaking a direct request outside the -proxy. +proxy. IPv6 proxy endpoints use brackets, for example `-socks=[::1]:9050`. ### When something looks wrong diff --git a/src/proxy.cpp b/src/proxy.cpp index 1e62ff9..d7dc324 100644 --- a/src/proxy.cpp +++ b/src/proxy.cpp @@ -5,6 +5,8 @@ #include "proxy.h" #include "sockcount.h" +#include + static CCriticalSection cs_socks5; static bool g_fSocks5Proxy = false; static std::string g_socks5Host; @@ -60,6 +62,40 @@ static bool ValidProxyHostChar(char c) return isalnum((unsigned char)c) || c == '.' || c == '-' || c == '_'; } +static bool ValidProxyIpv6HostChar(char c) +{ + return isxdigit((unsigned char)c) || c == ':' || c == '.'; +} + +static bool ParsePort(const std::string& port, unsigned short& portOut, + std::string& errOut) +{ + if (port.empty()) + { + errOut = "port is empty"; + return false; + } + for (size_t i = 0; i < port.size(); i++) + { + if (!isdigit((unsigned char)port[i])) + { + errOut = "port is not numeric"; + return false; + } + } + + errno = 0; + char* end = NULL; + long nPort = strtol(port.c_str(), &end, 10); + if (errno == ERANGE || end == NULL || *end != '\0' || nPort <= 0 || nPort > 65535) + { + errOut = "port is out of range"; + return false; + } + portOut = (unsigned short)nPort; + return true; +} + bool BtfParseSocks5Proxy(const std::string& spec, std::string& hostOut, unsigned short& portOut, std::string& errOut) { @@ -67,45 +103,76 @@ bool BtfParseSocks5Proxy(const std::string& spec, std::string& hostOut, portOut = 0; errOut.clear(); - size_t colon = spec.rfind(':'); - if (colon == std::string::npos || colon == 0 || colon + 1 >= spec.size()) + std::string host; + std::string port; + bool fBracketedIpv6 = false; + if (!spec.empty() && spec[0] == '[') { - errOut = "expected HOST:PORT"; - return false; + size_t close = spec.find(']'); + if (close == std::string::npos || close == 1 || close + 1 >= spec.size() || spec[close + 1] != ':') + { + errOut = "expected [IPv6]:PORT"; + return false; + } + host = spec.substr(1, close - 1); + port = spec.substr(close + 2); + fBracketedIpv6 = true; + } + else + { + size_t colon = spec.rfind(':'); + if (colon == std::string::npos || colon == 0 || colon + 1 >= spec.size()) + { + errOut = "expected HOST:PORT"; + return false; + } + host = spec.substr(0, colon); + port = spec.substr(colon + 1); + if (host.find(':') != std::string::npos) + { + errOut = "IPv6 proxy hosts must use [ADDR]:PORT"; + return false; + } } - std::string host = spec.substr(0, colon); - std::string port = spec.substr(colon + 1); if (host.size() > 255) { errOut = "host is too long"; return false; } - for (size_t i = 0; i < host.size(); i++) + if (fBracketedIpv6) { - if (!ValidProxyHostChar(host[i])) + bool fHasColon = false; + for (size_t i = 0; i < host.size(); i++) + { + fHasColon = fHasColon || host[i] == ':'; + if (!ValidProxyIpv6HostChar(host[i])) + { + errOut = "IPv6 proxy host contains unsupported characters"; + return false; + } + } + if (!fHasColon) { - errOut = "host contains unsupported characters"; + errOut = "bracketed proxy host is not IPv6"; return false; } } - for (size_t i = 0; i < port.size(); i++) + else { - if (!isdigit((unsigned char)port[i])) + for (size_t i = 0; i < host.size(); i++) { - errOut = "port is not numeric"; - return false; + if (!ValidProxyHostChar(host[i])) + { + errOut = "host contains unsupported characters"; + return false; + } } } - int nPort = atoi(port.c_str()); - if (nPort <= 0 || nPort > 65535) - { - errOut = "port is out of range"; + if (!ParsePort(port, portOut, errOut)) return false; - } hostOut = host; - portOut = (unsigned short)nPort; return true; } @@ -147,6 +214,8 @@ std::string BtfSocks5ProxyName() { if (!g_fSocks5Proxy) return ""; + if (g_socks5Host.find(':') != std::string::npos) + return strprintf("[%s]:%u", g_socks5Host.c_str(), (unsigned)g_socks5Port); return strprintf("%s:%u", g_socks5Host.c_str(), (unsigned)g_socks5Port); } return ""; @@ -207,8 +276,14 @@ static bool Socks5Connect(SOCKET s, const std::string& destHost, return false; unsigned char rep[4] = {0,0,0,0}; - if (!ReadN(s, rep, sizeof(rep)) || rep[0] != 0x05 || rep[1] != 0x00) + if (!ReadN(s, rep, sizeof(rep)) || rep[0] != 0x05) return false; + if (rep[1] != 0x00) + { + error("SOCKS5 CONNECT to %s:%u failed with reply 0x%02x\n", + destHost.c_str(), (unsigned)destPort, (unsigned)rep[1]); + return false; + } int nAddr = 0; if (rep[3] == 0x01) nAddr = 4; else if (rep[3] == 0x04) nAddr = 16; diff --git a/src/readme.txt b/src/readme.txt index 96f32c3..1fa77df 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -45,6 +45,7 @@ Command-line options (in addition to the original ones) /socks=HOST:PORT route outbound Nostr and .btf rendezvous dials through a SOCKS5 proxy, e.g. /socks=127.0.0.1:9050 for local Tor + or /socks=[::1]:9050 for an IPv6 loopback proxy /solomine mine without requiring a connected peer (local testing) /operator operator mode (run pool server) /participant=ADDR diff --git a/src/selftest.cpp b/src/selftest.cpp index 5b36b9d..aa4e528 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -9,6 +9,7 @@ #include "bip32.h" #include "proxy.h" #include "selftest.h" +#include "sockcount.h" #include "walletcmd.h" extern int RunPoolStratumSelfTest(); @@ -1254,6 +1255,197 @@ static int RunParseMoneySelfTest() return nFail == 0 ? 0 : 1; } +static bool SelfTestReadN(SOCKET s, void* buf, int n) +{ + char* p = (char*)buf; + int off = 0; + while (off < n) + { + int r = recv(s, p + off, n - off, 0); + if (r <= 0) + return false; + off += r; + } + return true; +} + +static bool SelfTestWriteN(SOCKET s, const void* buf, int n) +{ + const char* p = (const char*)buf; + int off = 0; + while (off < n) + { + int r = send(s, p + off, n - off, 0); + if (r <= 0) + return false; + off += r; + } + return true; +} + +static void SelfTestSetSocketTimeout(SOCKET s, int nTimeoutSecs) +{ +#ifdef _WIN32 + DWORD tv = (DWORD)nTimeoutSecs * 1000; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tv, sizeof(tv)); + setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (const char*)&tv, sizeof(tv)); +#else + struct timeval tv; + tv.tv_sec = nTimeoutSecs; + tv.tv_usec = 0; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); +#endif +} + +#ifdef _WIN32 +struct SelfTestWinsock +{ + bool fStarted; + SelfTestWinsock() : fStarted(false) + { + WSADATA wsadata; + fStarted = (WSAStartup(MAKEWORD(2,2), &wsadata) == 0); + } + ~SelfTestWinsock() + { + if (fStarted) + WSACleanup(); + } +}; +#endif + +static bool RunSocks5HandshakeProbe(std::string& errOut) +{ + errOut.clear(); +#ifdef _WIN32 + SelfTestWinsock winsock; + if (!winsock.fStarted) + { + errOut = "could not start Winsock"; + return false; + } +#endif + + SOCKET listener = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (listener == INVALID_SOCKET) + { + errOut = "could not create listener"; + return false; + } + SelfTestSetSocketTimeout(listener, 5); + + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; + if (bind(listener, (struct sockaddr*)&addr, sizeof(addr)) != 0 || + listen(listener, 1) != 0) + { + closesocket(listener); + errOut = "could not bind/listen on loopback"; + return false; + } + +#ifdef _WIN32 + int addrLen = sizeof(addr); +#else + socklen_t addrLen = sizeof(addr); +#endif + if (getsockname(listener, (struct sockaddr*)&addr, &addrLen) != 0) + { + closesocket(listener); + errOut = "could not read listener port"; + return false; + } + unsigned short proxyPort = ntohs(addr.sin_port); + + bool fServerOk = false; + std::string serverErr; + std::string requestedHost; + unsigned short requestedPort = 0; + std::thread server([&]() { + SOCKET s = accept(listener, NULL, NULL); + if (s == INVALID_SOCKET) + { + serverErr = "accept failed"; + return; + } + SelfTestSetSocketTimeout(s, 5); + + unsigned char hello[3] = {0,0,0}; + if (!SelfTestReadN(s, hello, sizeof(hello)) || + hello[0] != 0x05 || hello[1] != 0x01 || hello[2] != 0x00) + { + serverErr = "bad SOCKS5 greeting"; + closesocket(s); + return; + } + unsigned char choice[2] = {0x05, 0x00}; + if (!SelfTestWriteN(s, choice, sizeof(choice))) + { + serverErr = "could not write method choice"; + closesocket(s); + return; + } + + unsigned char hdr[5] = {0,0,0,0,0}; + if (!SelfTestReadN(s, hdr, sizeof(hdr)) || + hdr[0] != 0x05 || hdr[1] != 0x01 || hdr[2] != 0x00 || hdr[3] != 0x03) + { + serverErr = "CONNECT did not use domain-name address type"; + closesocket(s); + return; + } + + unsigned char len = hdr[4]; + std::vector rest((size_t)len + 2); + if (!SelfTestReadN(s, &rest[0], (int)rest.size())) + { + serverErr = "could not read CONNECT target"; + closesocket(s); + return; + } + requestedHost.assign((const char*)&rest[0], (size_t)len); + requestedPort = ((unsigned short)rest[len] << 8) | rest[len + 1]; + + unsigned char reply[10] = {0x05, 0x00, 0x00, 0x01, 0,0,0,0, 0,0}; + if (!SelfTestWriteN(s, reply, sizeof(reply))) + { + serverErr = "could not write CONNECT reply"; + closesocket(s); + return; + } + fServerOk = true; + closesocket(s); + }); + + std::string err; + BtfClearSocks5Proxy(); + bool fClientOk = BtfSetSocks5Proxy(strprintf("127.0.0.1:%u", (unsigned)proxyPort), err); + SOCKET client = INVALID_SOCKET; + if (fClientOk) + client = BtfConnectSocket("relay.example", 443, SOCK_NOSTR, 5); + if (client != INVALID_SOCKET) + BtfCloseSocket(client); + BtfClearSocks5Proxy(); + closesocket(listener); + server.join(); + + if (!fClientOk) + errOut = strprintf("could not enable proxy: %s", err.c_str()); + else if (client == INVALID_SOCKET) + errOut = "client SOCKS5 connect failed"; + else if (!fServerOk) + errOut = serverErr.empty() ? "mock server failed" : serverErr; + else if (requestedHost != "relay.example" || requestedPort != 443) + errOut = strprintf("unexpected CONNECT target %s:%u", requestedHost.c_str(), (unsigned)requestedPort); + else + return true; + return false; +} + static int RunSocks5ProxySelfTest() { printf("socks5-proxy self-test\n"); @@ -1267,12 +1459,19 @@ static int RunSocks5ProxySelfTest() nFail += Check(BtfParseSocks5Proxy("localhost:9050", host, port, err) && host == "localhost" && port == 9050, "parses hostname proxy endpoint") ? 0 : 1; + nFail += Check(BtfParseSocks5Proxy("[::1]:9050", host, port, err) && + host == "::1" && port == 9050, + "parses bracketed IPv6 proxy endpoint") ? 0 : 1; + nFail += Check(!BtfParseSocks5Proxy("::1:9050", host, port, err), + "rejects unbracketed IPv6 proxy endpoint") ? 0 : 1; nFail += Check(!BtfParseSocks5Proxy("localhost", host, port, err), "rejects missing port") ? 0 : 1; nFail += Check(!BtfParseSocks5Proxy("localhost:0", host, port, err), "rejects zero port") ? 0 : 1; nFail += Check(!BtfParseSocks5Proxy("localhost:70000", host, port, err), "rejects out-of-range port") ? 0 : 1; + nFail += Check(!BtfParseSocks5Proxy("localhost:999999999999999999999999999999", host, port, err), + "rejects overflowing port") ? 0 : 1; nFail += Check(!BtfParseSocks5Proxy("user:pass@localhost:9050", host, port, err), "rejects unsupported authenticated proxy syntax") ? 0 : 1; @@ -1285,6 +1484,19 @@ static int RunSocks5ProxySelfTest() BtfClearSocks5Proxy(); nFail += Check(!BtfSocks5ProxyEnabled(), "clear disables proxy") ? 0 : 1; + nFail += Check(BtfSetSocks5Proxy("[::1]:9050", err) && + BtfSocks5ProxyEnabled() && + BtfSocks5ProxyName() == "[::1]:9050", + "enables bracketed IPv6 proxy endpoint") ? 0 : 1; + BtfClearSocks5Proxy(); + + std::string handshakeErr; + bool fHandshakeOk = RunSocks5HandshakeProbe(handshakeErr); + nFail += Check(fHandshakeOk, handshakeErr.empty() ? + "completes SOCKS5 domain-name CONNECT handshake" : + strprintf("completes SOCKS5 domain-name CONNECT handshake (%s)", + handshakeErr.c_str()).c_str()) ? 0 : 1; + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", nFail, nFail == 1 ? "" : "s"); fflush(stdout); -- cgit v1.2.3 From 6092e1e7da199d6c15b9548d5d4cebcf8babfdcf Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Tue, 11 Aug 2026 16:03:12 -0300 Subject: net: add Tor mode for outbound discovery --- README.md | 9 +++++- docs/tor.md | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main_gui.cpp | 25 +++++++++++++---- src/proxy.cpp | 50 ++++++++++++++++++++++++++++++++- src/proxy.h | 3 ++ src/readme.txt | 3 ++ src/selftest.cpp | 25 +++++++++++++++++ 7 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 docs/tor.md (limited to 'src/selftest.cpp') diff --git a/README.md b/README.md index 353da25..66f3041 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,8 @@ Worth being precise, because the difference matters if you are relying on it. **The node still listens on 8433.** Nothing dials by IP any more, but the listener is still there, so anyone who already knows your address and can reach that port may connect directly. If that matters to you, firewall it. -This is unlinkability between peers, not anonymity against a network observer. It is not Tor. +This is unlinkability between peers, not anonymity against a network observer. +For Tor routing, start the node with `-tor`; see [Tor mode](docs/tor.md). --- @@ -311,6 +312,7 @@ Other options worth knowing: -datadir=PATH # wallet and chain data elsewhere -port=N # P2P listen port, default 8433 -socks=HOST:PORT # SOCKS5 for outbound Nostr and .btf rendezvous dials +-tor[=HOST:PORT] # Tor mode; default local Tor SOCKS5 proxy is 127.0.0.1:9050 -debug # verbose log; without it debug.log is nearly silent -help # full list ``` @@ -324,6 +326,11 @@ dials through a local SOCKS5 proxy such as Tor. When it is set, the node skips plain HTTP external-IP probes instead of leaking a direct request outside the proxy. IPv6 proxy endpoints use brackets, for example `-socks=[::1]:9050`. +`-tor` is a privacy shorthand for the usual local Tor SOCKS5 listener at +`127.0.0.1:9050`. It routes outbound Nostr discovery and `.btf` rendezvous +dials through Tor, refuses direct `.onion` resolution, and keeps external-IP +probes disabled. Use `-tor=HOST:PORT` when Tor listens somewhere else. + ### When something looks wrong **Menu > Diagnostics**, and the same report in `debug.log` every ten minutes: diff --git a/docs/tor.md b/docs/tor.md new file mode 100644 index 0000000..087c822 --- /dev/null +++ b/docs/tor.md @@ -0,0 +1,84 @@ +# Tor mode + +Bitflash can route outbound discovery traffic through a local Tor SOCKS5 proxy. +This is transport plumbing only: it does not change consensus, wallet keys, +mining, `.btf` addresses, or the encrypted rendezvous protocol. + +## Client mode + +Start Tor locally, then start Bitflash with: + +```bash +bitflash -tor +``` + +`-tor` is shorthand for: + +```bash +bitflash -socks=127.0.0.1:9050 +``` + +If Tor listens somewhere else: + +```bash +bitflash -tor=127.0.0.1:9150 +bitflash -tor=[::1]:9050 +``` + +When Tor mode is enabled: + +- outbound Nostr relay dials use Tor; +- outbound `.btf` rendezvous relay dials use Tor; +- destination names are sent to Tor as SOCKS5 domain-name CONNECT requests, so + the local DNS resolver does not see them; +- direct `.onion` dials are refused unless a SOCKS5/Tor proxy is enabled; +- plain HTTP external-IP probes are skipped. + +## Onion rendezvous relay + +An operator can expose a Bitflash rendezvous relay as a Tor hidden service. The +node does not create hidden services itself; Tor owns that configuration. + +Example `torrc`: + +```text +HiddenServiceDir /var/lib/tor/bitflash-rendezvous/ +HiddenServicePort 8434 127.0.0.1:8434 +``` + +After restarting Tor, read the generated onion name: + +```bash +sudo cat /var/lib/tor/bitflash-rendezvous/hostname +``` + +Then run the relay on localhost/VPS as usual and announce the onion endpoint from +nodes that use Tor: + +```bash +bitflash -tor -rvrelay=exampleexampleexampleexampleexampleexampleexampleexample.onion:8434 +bitflash -tor -announcerelay=exampleexampleexampleexampleexampleexampleexampleexample.onion:8434 +``` + +For a pool operator that wants the pool descriptor to point at an onion +rendezvous relay, use both: + +```bash +bitflash -operator -tor \ + -rvrelay=exampleexampleexampleexampleexampleexampleexampleexample.onion:8434 \ + -announcerelay=exampleexampleexampleexampleexampleexampleexampleexample.onion:8434 +``` + +## Full-node hidden service + +The legacy P2P listener still binds to port `8433`. You can expose it through +Tor manually: + +```text +HiddenServiceDir /var/lib/tor/bitflash-node/ +HiddenServicePort 8433 127.0.0.1:8433 +``` + +Current automatic peer discovery is `.btf`/rendezvous based, not onion-address +gossip. Treat direct full-node onion access as an operator/admin tool unless a +future release adds first-class onion peer advertisements. diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 90a6050..5194023 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -138,6 +138,7 @@ static void PrintUsage() printf("Network:\n"); printf(" /port=N (P2P listen port, default 8433)\n"); printf(" /socks=HOST:PORT (SOCKS5 proxy for outbound Nostr and .btf relay dials)\n"); + printf(" /tor[=HOST:PORT] (Tor mode; default SOCKS5 proxy is 127.0.0.1:9050)\n"); printf(" /btfseed=ADDRESS:ENCHEX (extra bootstrap peer, repeatable)\n"); printf("\n"); printf("Wallet:\n"); @@ -285,16 +286,30 @@ static void ParseStartupArguments(int argc, char* argv[]) if (!announceRelay.empty()) strBtfAnnounceRelay = announceRelay; - string socksProxy = argval2(argc, argv, "/socks", "-socks"); - if (!socksProxy.empty()) + bool fTorMode = arg(argc, argv, "/tor") || arg(argc, argv, "-tor"); + if (fTorMode) { + string torProxy = argval2(argc, argv, "/tor", "-tor"); string err; - if (!BtfSetSocks5Proxy(socksProxy, err)) - fprintf(stderr, "Ignoring /socks=%s: %s\n", socksProxy.c_str(), err.c_str()); + if (!BtfEnableTorProxy(torProxy, err)) + fprintf(stderr, "Ignoring /tor=%s: %s\n", torProxy.c_str(), err.c_str()); else - fprintf(stderr, "SOCKS5 proxy enabled for outbound discovery/relay dials: %s\n", + fprintf(stderr, "Tor mode enabled through SOCKS5 proxy %s\n", BtfSocks5ProxyName().c_str()); } + else + { + string socksProxy = argval2(argc, argv, "/socks", "-socks"); + if (!socksProxy.empty()) + { + string err; + if (!BtfSetSocks5Proxy(socksProxy, err)) + fprintf(stderr, "Ignoring /socks=%s: %s\n", socksProxy.c_str(), err.c_str()); + else + fprintf(stderr, "SOCKS5 proxy enabled for outbound discovery/relay dials: %s\n", + BtfSocks5ProxyName().c_str()); + } + } // /btfseed=ADDRESS:ENCHEX -- extra bootstrap peers, repeatable. Useful for // testing the seed path and for private networks that ship no compiled list. diff --git a/src/proxy.cpp b/src/proxy.cpp index d7dc324..7d30911 100644 --- a/src/proxy.cpp +++ b/src/proxy.cpp @@ -9,8 +9,10 @@ static CCriticalSection cs_socks5; static bool g_fSocks5Proxy = false; +static bool g_fTorProxy = false; static std::string g_socks5Host; static unsigned short g_socks5Port = 0; +static const char* DEFAULT_TOR_SOCKS5_PROXY = "127.0.0.1:9050"; static void SetSocketTimeout(SOCKET s, int nTimeoutSecs) { @@ -67,6 +69,24 @@ static bool ValidProxyIpv6HostChar(char c) return isxdigit((unsigned char)c) || c == ':' || c == '.'; } +static std::string LowerProxyHost(std::string host) +{ + if (!host.empty() && host[host.size() - 1] == '.') + host.erase(host.size() - 1); + for (size_t i = 0; i < host.size(); i++) + host[i] = (char)tolower((unsigned char)host[i]); + return host; +} + +bool BtfIsTorOnionHost(const std::string& host) +{ + std::string lower = LowerProxyHost(host); + static const char* suffix = ".onion"; + size_t suffixLen = strlen(suffix); + return lower.size() > suffixLen && + lower.compare(lower.size() - suffixLen, suffixLen, suffix) == 0; +} + static bool ParsePort(const std::string& port, unsigned short& portOut, std::string& errOut) { @@ -176,7 +196,8 @@ bool BtfParseSocks5Proxy(const std::string& spec, std::string& hostOut, return true; } -bool BtfSetSocks5Proxy(const std::string& spec, std::string& errOut) +static bool SetSocks5Proxy(const std::string& spec, bool fTorMode, + std::string& errOut) { std::string host; unsigned short port = 0; @@ -187,15 +208,27 @@ bool BtfSetSocks5Proxy(const std::string& spec, std::string& errOut) g_socks5Host = host; g_socks5Port = port; g_fSocks5Proxy = true; + g_fTorProxy = fTorMode; } return true; } +bool BtfSetSocks5Proxy(const std::string& spec, std::string& errOut) +{ + return SetSocks5Proxy(spec, false, errOut); +} + +bool BtfEnableTorProxy(const std::string& spec, std::string& errOut) +{ + return SetSocks5Proxy(spec.empty() ? DEFAULT_TOR_SOCKS5_PROXY : spec, true, errOut); +} + void BtfClearSocks5Proxy() { CRITICAL_BLOCK(cs_socks5) { g_fSocks5Proxy = false; + g_fTorProxy = false; g_socks5Host.clear(); g_socks5Port = 0; } @@ -208,6 +241,13 @@ bool BtfSocks5ProxyEnabled() return false; } +bool BtfTorProxyEnabled() +{ + CRITICAL_BLOCK(cs_socks5) + return g_fSocks5Proxy && g_fTorProxy; + return false; +} + std::string BtfSocks5ProxyName() { CRITICAL_BLOCK(cs_socks5) @@ -315,7 +355,15 @@ SOCKET BtfConnectSocket(const std::string& destHost, unsigned short destPort, } if (!fProxy) + { + if (BtfIsTorOnionHost(destHost)) + { + error("Refusing direct .onion connection to %s:%u; use /tor or /socks\n", + destHost.c_str(), (unsigned)destPort); + return INVALID_SOCKET; + } return ConnectDirect(destHost, destPort, nSockSite, nTimeoutSecs); + } SOCKET s = ConnectDirect(proxyHost, proxyPort, nSockSite, nTimeoutSecs); if (s == INVALID_SOCKET) diff --git a/src/proxy.h b/src/proxy.h index b5e69c8..6429cf2 100644 --- a/src/proxy.h +++ b/src/proxy.h @@ -21,8 +21,11 @@ bool BtfParseSocks5Proxy(const std::string& spec, std::string& hostOut, unsigned short& portOut, std::string& errOut); bool BtfSetSocks5Proxy(const std::string& spec, std::string& errOut); +bool BtfEnableTorProxy(const std::string& spec, std::string& errOut); void BtfClearSocks5Proxy(); bool BtfSocks5ProxyEnabled(); +bool BtfTorProxyEnabled(); +bool BtfIsTorOnionHost(const std::string& host); std::string BtfSocks5ProxyName(); SOCKET BtfConnectSocket(const std::string& destHost, unsigned short destPort, diff --git a/src/readme.txt b/src/readme.txt index 1fa77df..794fe20 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -46,6 +46,9 @@ Command-line options (in addition to the original ones) route outbound Nostr and .btf rendezvous dials through a SOCKS5 proxy, e.g. /socks=127.0.0.1:9050 for local Tor or /socks=[::1]:9050 for an IPv6 loopback proxy + /tor[=HOST:PORT] + Tor mode for outbound Nostr and .btf rendezvous dials; + defaults to the local Tor SOCKS5 proxy at 127.0.0.1:9050 /solomine mine without requiring a connected peer (local testing) /operator operator mode (run pool server) /participant=ADDR diff --git a/src/selftest.cpp b/src/selftest.cpp index aa4e528..cca5335 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1474,6 +1474,11 @@ static int RunSocks5ProxySelfTest() "rejects overflowing port") ? 0 : 1; nFail += Check(!BtfParseSocks5Proxy("user:pass@localhost:9050", host, port, err), "rejects unsupported authenticated proxy syntax") ? 0 : 1; + nFail += Check(BtfIsTorOnionHost("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcd.onion") && + BtfIsTorOnionHost("Relay.Example.ONION.") && + !BtfIsTorOnionHost("relay.example") && + !BtfIsTorOnionHost(".onion"), + "detects .onion hosts without accepting lookalikes") ? 0 : 1; BtfClearSocks5Proxy(); nFail += Check(!BtfSocks5ProxyEnabled(), "starts disabled after clear") ? 0 : 1; @@ -1490,6 +1495,26 @@ static int RunSocks5ProxySelfTest() "enables bracketed IPv6 proxy endpoint") ? 0 : 1; BtfClearSocks5Proxy(); + nFail += Check(BtfEnableTorProxy("", err) && + BtfSocks5ProxyEnabled() && + BtfTorProxyEnabled() && + BtfSocks5ProxyName() == "127.0.0.1:9050", + "enables Tor mode on the default local SOCKS5 endpoint") ? 0 : 1; + BtfClearSocks5Proxy(); + + nFail += Check(BtfEnableTorProxy("[::1]:9050", err) && + BtfSocks5ProxyEnabled() && + BtfTorProxyEnabled() && + BtfSocks5ProxyName() == "[::1]:9050", + "enables Tor mode on a custom IPv6 endpoint") ? 0 : 1; + BtfClearSocks5Proxy(); + + nFail += Check(BtfSetSocks5Proxy("127.0.0.1:9051", err) && + BtfSocks5ProxyEnabled() && + !BtfTorProxyEnabled(), + "plain SOCKS5 mode is distinct from Tor mode") ? 0 : 1; + BtfClearSocks5Proxy(); + std::string handshakeErr; bool fHandshakeOk = RunSocks5HandshakeProbe(handshakeErr); nFail += Check(fHandshakeOk, handshakeErr.empty() ? -- cgit v1.2.3 From a109dcd23fd387324e63f265a237723ba1d6c4fa Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Wed, 12 Aug 2026 14:44:43 -0300 Subject: wallet: collapse restored legacy hdnext --- src/selftest.cpp | 28 +++++++++++++++++++++++++ src/walletcmd.cpp | 62 +++++++++++++++++++++++++++++++++++++++++++++++++------ src/walletcmd.h | 9 ++++++++ 3 files changed, 93 insertions(+), 6 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/selftest.cpp b/src/selftest.cpp index cca5335..bcbf9d3 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -586,6 +586,34 @@ static int RunWalletHDSelfTest() "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; + + CKey keyLegacyHD11; + nSchemaForLegacyAudit = nHDKeySchema; + nHDKeySchema = HD_SCHEMA_LEGACY; + if (!DeriveHDKey(11, keyLegacyHD11, strError)) + throw std::runtime_error("legacy compatibility index 11 derivation failed: " + strError); + nHDKeySchema = nSchemaForLegacyAudit; + if (!AddKey(keyLegacyHD11)) + throw std::runtime_error("could not store the legacy compatibility index 11 key"); + + std::map > mapLegacyProbe; + mapLegacyProbe[0] = keyAuditLegacyHD.GetPubKey(); + mapLegacyProbe[11] = keyLegacyHD11.GetPubKey(); + + CWalletTx wtxLegacyHD11; + wtxLegacyHD11.vout.push_back(CTxOut(19 * COIN, CScript() << keyLegacyHD11.GetPubKey() << OP_CHECKSIG)); + CRITICAL_BLOCK(cs_mapWallet) + { + mapWallet.clear(); + mapWallet[wtxLegacyHD11.GetHash()] = wtxLegacyHD11; + } + nFail += Check(WalletLastUsedPubKeyIndexNext(mapLegacyProbe) == 12, + "restore collapses legacy hdnext to the last used compatibility index plus one") ? 0 : 1; + + CRITICAL_BLOCK(cs_mapWallet) + mapWallet.clear(); + nFail += Check(WalletLastUsedPubKeyIndexNext(mapLegacyProbe) == 0, + "restore does not persist legacy hdnext at the scan depth when no compatibility key was used") ? 0 : 1; } catch (const std::exception& e) { diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index 0ea2251..e8ac4bf 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -54,6 +54,37 @@ bool RestoreScanReachedDepth(int nSchema, return nLegacyNext >= nDepth; } +unsigned int WalletLastUsedPubKeyIndexNext( + const std::map >& mapPubKeysByIndex) +{ + std::map, unsigned int> mapIndexByPubKey; + for (std::map >::const_iterator it = + mapPubKeysByIndex.begin(); + it != mapPubKeysByIndex.end(); ++it) + mapIndexByPubKey[it->second] = it->first; + + unsigned int nNext = 0; + CRITICAL_BLOCK(cs_mapWallet) + { + for (map::iterator it = mapWallet.begin(); + it != mapWallet.end(); ++it) + { + CWalletTx& wtx = (*it).second; + for (int i = 0; i < (int)wtx.vout.size(); i++) + { + vector vchPubKey; + if (!ExtractPubKey(wtx.vout[i].scriptPubKey, false, vchPubKey)) + continue; + std::map, unsigned int>::const_iterator mi = + mapIndexByPubKey.find(vchPubKey); + if (mi != mapIndexByPubKey.end() && mi->second + 1 > nNext) + nNext = mi->second + 1; + } + } + } + return nNext; +} + int CmdNewPhrase() { AttachTerminal(); @@ -132,7 +163,10 @@ bool RestoreFromPhrase(const std::string& strMnemonic, // Derive forward in batches, scanning after each, until a whole batch turns // up nothing. Every derived key is written to the wallet before the scan, // because the scan asks the wallet what belongs to it. - int nTotalDerived = nHDKeySchema == HD_SCHEMA_BIP44 + const bool fRestoreBIP44 = nHDKeySchema == HD_SCHEMA_BIP44; + unsigned int nLegacyScanNext = nHDNext; + std::map > mapLegacyScanPubKeys; + int nTotalDerived = fRestoreBIP44 ? (int)(nHDReceiveNext + nHDChangeNext) : (int)nHDNext; int nStopDepth = max(nMinDepth, RESTORE_MIN_SCAN); @@ -151,7 +185,7 @@ bool RestoreFromPhrase(const std::string& strMnemonic, nWalletBefore = mapWallet.size(); std::string strDeriveError; - if (nHDKeySchema == HD_SCHEMA_BIP44) + if (fRestoreBIP44) { for (int i = 0; i < RESTORE_BATCH; i++) { @@ -182,15 +216,15 @@ bool RestoreFromPhrase(const std::string& strMnemonic, for (int i = 0; i < RESTORE_BATCH; i++) { CKey key; - if (!DeriveHDKey(nHDNext, key, strDeriveError)) + if (!DeriveHDKey(nLegacyScanNext, key, strDeriveError)) break; if (!AddKey(key)) break; - nHDNext++; + mapLegacyScanPubKeys[nLegacyScanNext] = key.GetPubKey(); + nLegacyScanNext++; nTotalDerived++; } nHDKeySchema = nSavedSchema; - CWalletDB().WriteHDNext(nHDNext); } else { @@ -209,6 +243,22 @@ bool RestoreFromPhrase(const std::string& strMnemonic, ScanForWalletTransactions(pindexGenesisBlock); + if (fRestoreBIP44) + { + // The legacy branch is scanned only for compatibility with phrases + // created before BIP44. Persist the last used legacy index + 1, not + // the lookahead depth. Otherwise a restore with no legacy hits can + // leave hdnext at RESTORE_MAX and make future compatibility scans + // look exhausted even though no legacy key was ever used. + unsigned int nLegacyUsedNext = + WalletLastUsedPubKeyIndexNext(mapLegacyScanPubKeys); + if (nLegacyUsedNext != nHDNext) + { + nHDNext = nLegacyUsedNext; + CWalletDB().WriteHDNext(nHDNext); + } + } + size_t nWalletAfter = 0; CRITICAL_BLOCK(cs_mapWallet) nWalletAfter = mapWallet.size(); @@ -239,7 +289,7 @@ bool RestoreFromPhrase(const std::string& strMnemonic, RestoreScanReachedDepth(nHDKeySchema, nHDReceiveNext, nHDChangeNext, - nHDNext, + fRestoreBIP44 ? nLegacyScanNext : nHDNext, nStopDepth)) break; } diff --git a/src/walletcmd.h b/src/walletcmd.h index 04d3fb7..007b8b5 100644 --- a/src/walletcmd.h +++ b/src/walletcmd.h @@ -7,7 +7,9 @@ #ifndef BITFLASH_WALLETCMD_H #define BITFLASH_WALLETCMD_H +#include #include +#include // Create a phrase for a wallet that does not have one, install the seed, and // show the words once. Refuses if a phrase already exists. Returns 0 on success. @@ -41,6 +43,13 @@ bool RestoreScanReachedDepth(int nSchema, unsigned int nLegacyNext, int nStopDepth); +// Given the pubkeys derived while scanning a compatibility branch, return the +// next index after the highest one that actually appears in wallet +// transactions. The scan depth can be much larger than this; hdnext must record +// use, not how far a restore looked ahead. +unsigned int WalletLastUsedPubKeyIndexNext( + const std::map >& mapPubKeysByIndex); + // Take the next address from the key pool and print it. With a recovery phrase // installed the address is derived, so the phrase can bring back whatever is // paid to it. -- cgit v1.2.3 From d2b86473a267c960a1f0c012a983176e5d962187 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Thu, 13 Aug 2026 11:11:59 -0300 Subject: net: add signed direct onion peer endpoints --- README.md | 26 ++++++++----- docs/rendezvous.md | 70 +++++++++++++++++++++++++++++------ docs/tor.md | 39 ++++++++++++++++++-- src/btfaddr.cpp | 102 +++++++++++++++++++++++++++++++++++++++++++++++---- src/btfaddr.h | 8 +++- src/main_gui.cpp | 15 +++++++- src/net.cpp | 102 +++++++++++++++++++++++++++++++++++++++++---------- src/net.h | 3 +- src/nostr.cpp | 76 +++++++++++++++++++++++++++++++------- src/nostr.h | 12 +++++- src/readme.txt | 8 ++-- src/selftest.cpp | 9 +++++ src/sockcount.h | 1 + src/test_btfaddr.cpp | 22 +++++++++++ 14 files changed, 420 insertions(+), 73 deletions(-) (limited to 'src/selftest.cpp') diff --git a/README.md b/README.md index e671302..181b6e8 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Bitflash keeps Satoshi's original consensus rules and replaces two things: **RandomX proof of work.** Memory-hard algorithm used by Monero. A laptop competes equally with a server. ASICs and GPUs have no advantage. -**Anonymous addressing.** Every node has a `.btf` address derived from its public key, similar to a Tor `.onion`. Nodes reach each other through encrypted rendezvous tunnels, so no port forwarding is needed and a node behind CGNAT works normally. +**Anonymous addressing.** Every node has a `.btf` address derived from its public key, similar to a Tor `.onion`. Nodes reach each other through encrypted rendezvous tunnels by default, and can advertise a signed direct `.onion` endpoint for Tor peers. No premine. No ICO. 50 BTF per block, halving on schedule, 21M cap, ~2 minute blocks. @@ -30,14 +30,15 @@ No premine. No ICO. 50 BTF per block, halving on schedule, 21M cap, ~2 minute bl Worth being precise, because the difference matters if you are relying on it. -**A peer you reach over `.btf` does not learn your IP.** All outbound connections go through a rendezvous tunnel; there is no IP-based peer dialling left in the node. The relay forwards encrypted bytes and cannot read or alter them. +**A peer you reach over `.btf` does not learn your IP.** Outbound peer discovery uses rendezvous tunnels by default. If both sides use Tor and the peer advertises a signed `.onion` endpoint, the node connects directly to that hidden service instead. In both cases the peer does not see your clearnet IP. -**The rendezvous relay does see your IP.** It has to — it is the thing your TCP connection terminates on. Relays are run by volunteers, so treat that as a party who knows you are on the network. +**The rendezvous relay sees your IP unless you use Tor.** It has to — it is the thing your TCP connection terminates on. With `-tor`, the relay sees Tor instead. With a signed direct `.onion` peer endpoint, the relay is not on that connection path at all. **The node still listens on 8433.** Nothing dials by IP any more, but the listener is still there, so anyone who already knows your address and can reach that port may connect directly. If that matters to you, firewall it. This is unlinkability between peers, not anonymity against a network observer. -For Tor routing, start the node with `-tor`; see [Tor mode](docs/tor.md). +For Tor routing and direct onion peer endpoints, start the node with `-tor`; +see [Tor mode](docs/tor.md). For a plain SOCKS5 proxy without Tor-specific defaults, use `-socks=HOST:PORT`. @@ -336,8 +337,9 @@ Other options worth knowing: ```bash -datadir=PATH # wallet and chain data elsewhere -port=N # P2P listen port, default 8433 --socks=HOST:PORT # SOCKS5 for outbound Nostr and .btf rendezvous dials +-socks=HOST:PORT # SOCKS5 for outbound Nostr, .btf relay, and onion peer dials -tor[=HOST:PORT] # Tor mode; default local Tor SOCKS5 proxy is 127.0.0.1:9050 +-onionservice=HOST.onion:PORT # advertise this node's Tor hidden service -debug # verbose log; without it debug.log is nearly silent -help # full list ``` @@ -346,15 +348,19 @@ Other options worth knowing: needed — the data directory takes an exclusive lock, so a second node pointed at the same one will refuse to start. -`-socks=127.0.0.1:9050` routes outbound Nostr discovery and `.btf` rendezvous -dials through a local SOCKS5 proxy such as Tor. When it is set, the node skips +`-socks=127.0.0.1:9050` routes outbound Nostr discovery, `.btf` rendezvous +dials, and direct `.onion` peer dials through a local SOCKS5 proxy such as Tor. +When it is set, the node skips plain HTTP external-IP probes instead of leaking a direct request outside the proxy. IPv6 proxy endpoints use brackets, for example `-socks=[::1]:9050`. `-tor` is a privacy shorthand for the usual local Tor SOCKS5 listener at -`127.0.0.1:9050`. It routes outbound Nostr discovery and `.btf` rendezvous -dials through Tor, refuses direct `.onion` resolution, and keeps external-IP -probes disabled. Use `-tor=HOST:PORT` when Tor listens somewhere else. +`127.0.0.1:9050`. It routes outbound Nostr discovery, `.btf` rendezvous dials, +and signed direct `.onion` peer dials through Tor, and keeps external-IP probes +disabled. Use `-tor=HOST:PORT` when Tor listens somewhere else. To make your own +node reachable without a rendezvous relay, expose port `8433` as a Tor hidden +service and pass `-onionservice=HOST.onion:8433`; peers with `-tor` will try it +before falling back to rendezvous. ### When something looks wrong diff --git a/docs/rendezvous.md b/docs/rendezvous.md index ac8e598..e1888b9 100644 --- a/docs/rendezvous.md +++ b/docs/rendezvous.md @@ -18,11 +18,16 @@ A connection to a `.btf` service has three layers: 1. **Discovery (Nostr).** The service publishes a signed descriptor as a Nostr event. A caller fetches it by the service public key and verifies the signature. The descriptor says which rendezvous relay the service is waiting - at and which encryption key to use. + at, which encryption key to use, and optionally which Tor hidden service can + be dialed directly. 2. **Rendezvous (TCP).** Caller and service both connect out to the same relay and are paired by public key. Neither needs an inbound port or a public IP. After pairing the relay is a blind byte pipe. -3. **Encrypted channel (X25519 + XChaCha20-Poly1305).** Over the paired pipe the +3. **Direct onion fallback bypass.** If the descriptor carries a valid signed + `.onion:port` endpoint and the caller is running with Tor mode, the caller + tries that endpoint before using rendezvous. This removes the rendezvous + relay from the data path for peers that choose to run hidden services. +4. **Encrypted channel (X25519 + XChaCha20-Poly1305).** Over the paired pipe the caller and service run an ephemeral to static handshake and then exchange authenticated frames. The relay sees only ciphertext. @@ -107,6 +112,8 @@ The default rendezvous port is `8434`. The descriptor is a JSON object, signed with the identity key over a domain separated digest of its own fields. +Legacy descriptor: + ```json { "v": 2, @@ -118,7 +125,22 @@ separated digest of its own fields. } ``` -The signed digest is: +Onion-capable descriptor: + +```json +{ + "v": 3, + "pubkey": "", + "enc": "", + "meeting_node": "host:port", + "onion": "<56-char-v3-name>.onion:8433", + "created": , + "sig": "", + "sig3": "" +} +``` + +The legacy signed digest is: ``` digest = SHA256( ".btf-descriptor-v2" @@ -128,17 +150,37 @@ digest = SHA256( ".btf-descriptor-v2" || created (8 bytes, big endian) ) ``` -The signature is a deterministic BIP340 Schnorr signature (no auxiliary -randomness) over `digest` by the identity key. +`sig` is kept on v3 descriptors so old nodes can still resolve and dial through +the rendezvous relay. The onion-capable digest is: + +``` +digest3 = SHA256( ".btf-descriptor-v3-onion" + || pubkey[32] + || enc (raw bytes of the string) + || meeting_node (raw bytes of the string) + || onion (raw bytes of the normalized string) + || created (8 bytes, big endian) ) +``` + +`sig` and `sig3` are deterministic BIP340 Schnorr signatures (no auxiliary +randomness) over their respective digests by the identity key. **Verification** (untrusted input, must never throw): parse the JSON, require all fields with the right types, confirm `pubkey` equals the address being resolved, recompute the digest, and verify the Schnorr signature. Any failure rejects the descriptor. +If `onion` is present, readers accept it only when it is a normalized v3 onion +endpoint and `sig3` verifies. A bad or tampered onion field is ignored; the v2 +descriptor can still be used for rendezvous fallback if `sig` verifies. + `created` lets a reader prefer the freshest descriptor when more than one is -seen. A node only publishes a descriptor once it has actually registered at a -`meeting_node`, so it never advertises a relay it cannot be reached at. +seen. A node without a direct onion endpoint only publishes a descriptor once it +has actually registered at a `meeting_node`, so it never advertises a relay it +cannot be reached at. A node with a direct onion endpoint may publish before +rendezvous registration using `meeting_node = "rendezvous-pending"`; Tor peers +can still dial the signed onion endpoint, and rendezvous becomes fallback once +registration succeeds. ## 6. Rendezvous transport @@ -243,8 +285,9 @@ tunnel. not published anywhere. - **Forward secrecy.** The caller's per connection ephemeral key means a later compromise of the service encryption key does not decrypt past sessions. -- **IP privacy.** Neither side needs a public IP or an inbound port. The observable - network fact is that each side has a TCP connection to a relay. +- **IP privacy.** Neither side needs a public IP or an inbound port for + rendezvous. With a signed `.onion` endpoint, the reachable side exposes only a + Tor hidden service and the relay is not in the peer connection path. ## 10. Threat model and limitations @@ -265,6 +308,9 @@ tunnel. unreachable, discovery stalls until one is reachable again. Relay TLS is verified, which closes a man in the middle path on discovery, but availability still rests on the relay set. +- **Direct onion is not automatic Tor configuration.** Bitflash signs and + advertises a hidden-service endpoint, and Tor-mode peers can dial it. The Tor + daemon still owns hidden-service creation, private keys, rotation and uptime. ## 11. Wire reference @@ -273,9 +319,9 @@ tunnel. | `.btf` Base32 alphabet | `abcdefghijklmnopqrstuvwxyz234567`, no padding | | Address payload | `pubkey[32] || checksum[2]`, 55 Base32 chars | | Address checksum | `SHA256(".btf-checksum-v1" || pubkey)[0..2]` | -| Descriptor domain sep | `.btf-descriptor-v2` | -| Descriptor version | `2` | -| Descriptor signature | BIP340 Schnorr, deterministic (no aux rand) | +| Descriptor domain sep | `.btf-descriptor-v2`; optional onion sep `.btf-descriptor-v3-onion` | +| Descriptor version | `2`; `3` when a signed onion endpoint is present | +| Descriptor signature | BIP340 Schnorr, deterministic (no aux rand), v3 keeps `sig` plus `sig3` | | Nostr descriptor event | kind `38501`, `d = btf-descriptor-2` | | Nostr relay event | kind `38502`, `d = btf-relay-2` | | Nostr pool event | kind `38503`, `d = btf-pool-2` | diff --git a/docs/tor.md b/docs/tor.md index 69b29f1..b4f88f9 100644 --- a/docs/tor.md +++ b/docs/tor.md @@ -40,6 +40,8 @@ When Tor mode is enabled: - outbound Nostr relay dials use Tor; - outbound `.btf` rendezvous relay dials use Tor; +- outbound direct peer dials prefer signed `.onion` endpoints when a peer + advertises one; - destination names are sent to Tor as SOCKS5 domain-name CONNECT requests, so the local DNS resolver does not see them; - direct `.onion` dials are refused unless a SOCKS5/Tor proxy is enabled; @@ -98,7 +100,7 @@ bitflash -operator -tor \ -announcerelay=exampleexampleexampleexampleexampleexampleexampleexample.onion:8434 ``` -## Full-node hidden service +## Direct full-node hidden service The legacy P2P listener still binds to port `8433`. You can expose it through Tor manually: @@ -108,6 +110,35 @@ HiddenServiceDir /var/lib/tor/bitflash-node/ HiddenServicePort 8433 127.0.0.1:8433 ``` -Current automatic peer discovery is `.btf`/rendezvous based, not onion-address -gossip. Treat direct full-node onion access as an operator/admin tool unless a -future release adds first-class onion peer advertisements. +After restarting Tor, read the generated onion name: + +```bash +sudo cat /var/lib/tor/bitflash-node/hostname +``` + +Then start Bitflash with Tor outbound enabled and advertise that hidden service: + +```bash +bitflash -tor \ + -onionservice=exampleexampleexampleexampleexampleexampleexampleexample.onion:8433 +``` + +That onion endpoint is signed into this node's self-certifying `.btf` +descriptor. New peers that resolve the descriptor and also run with `-tor` try +the `.onion:8433` P2P connection first. If it fails, they fall back to the +ordinary encrypted rendezvous path. Older nodes ignore the onion field and still +use the rendezvous relay because the descriptor keeps the legacy signature too. + +If the node has `-onionservice` but has not registered at any rendezvous relay +yet, it can still publish an onion-capable descriptor with +`meeting_node = rendezvous-pending`. Tor-capable peers can dial the hidden +service directly from that descriptor. Rendezvous becomes a fallback, not a +precondition for being discoverable. + +This means rendezvous relays stop being the only way to reach a node that has +published a hidden service. They remain useful bootstrap/fallback infrastructure, +but an already-discovered onion-capable peer can be reached directly over Tor. + +The node does not create or rotate Tor hidden services itself. Tor owns the +private key under `HiddenServiceDir`; back it up if you want the onion name to +stay stable. diff --git a/src/btfaddr.cpp b/src/btfaddr.cpp index 6edcc91..ce2ac86 100644 --- a/src/btfaddr.cpp +++ b/src/btfaddr.cpp @@ -19,6 +19,8 @@ #include #include #include +#include +#include using json = nlohmann::json; @@ -166,10 +168,47 @@ static bool HexDecode(const std::string& s, unsigned char* out, size_t n) return true; } +bool NormalizeOnionEndpoint(const std::string& in, std::string& out) +{ + out.clear(); + std::string s = ToLowerTrim(in); + size_t colon = s.rfind(':'); + if (colon == std::string::npos || colon == 0 || colon + 1 >= s.size()) + return false; + + std::string host = s.substr(0, colon); + std::string port = s.substr(colon + 1); + while (!host.empty() && host.back() == '.') host.pop_back(); + static const char* suffix = ".onion"; + const size_t suffixLen = strlen(suffix); + if (host.size() != 56 + suffixLen) + return false; + if (host.compare(host.size() - suffixLen, suffixLen, suffix) != 0) + return false; + for (size_t i = 0; i < 56; i++) + { + char c = host[i]; + if (!((c >= 'a' && c <= 'z') || (c >= '2' && c <= '7'))) + return false; + } + for (size_t i = 0; i < port.size(); i++) + if (!isdigit((unsigned char)port[i])) + return false; + + errno = 0; + char* end = NULL; + long nPort = strtol(port.c_str(), &end, 10); + if (errno == ERANGE || end == NULL || *end != '\0' || nPort <= 0 || nPort > 65535) + return false; + + out = host + ":" + port; + return true; +} + // Digest signed over the descriptor fields (domain-separated). -static void DescriptorDigest(const unsigned char pubkey[32], const std::string& enc, - const std::string& meeting_node, uint64_t created, - unsigned char out[32]) +static void DescriptorDigestV2(const unsigned char pubkey[32], const std::string& enc, + const std::string& meeting_node, uint64_t created, + unsigned char out[32]) { static const char* DS = ".btf-descriptor-v2"; SHA256_CTX ctx; @@ -184,9 +223,27 @@ static void DescriptorDigest(const unsigned char pubkey[32], const std::string& SHA256_Final(out, &ctx); } +static void DescriptorDigestV3(const unsigned char pubkey[32], const std::string& enc, + const std::string& meeting_node, const std::string& onion, + uint64_t created, unsigned char out[32]) +{ + static const char* DS = ".btf-descriptor-v3-onion"; + SHA256_CTX ctx; + SHA256_Init(&ctx); + SHA256_Update(&ctx, (const unsigned char*)DS, strlen(DS)); + SHA256_Update(&ctx, pubkey, 32); + SHA256_Update(&ctx, (const unsigned char*)enc.data(), enc.size()); + SHA256_Update(&ctx, (const unsigned char*)meeting_node.data(), meeting_node.size()); + SHA256_Update(&ctx, (const unsigned char*)onion.data(), onion.size()); + unsigned char be[8]; + for (int i = 0; i < 8; i++) be[i] = (unsigned char)((created >> (8 * (7 - i))) & 0xff); + SHA256_Update(&ctx, be, 8); + SHA256_Final(out, &ctx); +} + std::string SignDescriptor(void* ctxv, const unsigned char seckey[32], const std::string& enc, const std::string& meeting_node, - uint64_t created) + uint64_t created, const std::string& onion) { secp256k1_context* ctx = (secp256k1_context*)ctxv; secp256k1_keypair kp; @@ -197,17 +254,28 @@ std::string SignDescriptor(void* ctxv, const unsigned char seckey[32], secp256k1_xonly_pubkey_serialize(ctx, pubkey, &xpub); unsigned char digest[32]; - DescriptorDigest(pubkey, enc, meeting_node, created, digest); + DescriptorDigestV2(pubkey, enc, meeting_node, created, digest); unsigned char sig[64]; // Deterministic signature (NULL aux_rand), matching Itzal's no-aux-rand signing. if (!secp256k1_schnorrsig_sign32(ctx, sig, digest, &kp, NULL)) return std::string(); json v = json::object(); - v["v"] = 2; + v["v"] = onion.empty() ? 2 : 3; v["pubkey"] = HexEncode(pubkey, 32); v["enc"] = enc; v["meeting_node"] = meeting_node; + if (!onion.empty()) + { + std::string onionNorm; + if (!NormalizeOnionEndpoint(onion, onionNorm)) return std::string(); + unsigned char digest3[32]; + DescriptorDigestV3(pubkey, enc, meeting_node, onionNorm, created, digest3); + unsigned char sig3[64]; + if (!secp256k1_schnorrsig_sign32(ctx, sig3, digest3, &kp, NULL)) return std::string(); + v["onion"] = onionNorm; + v["sig3"] = HexEncode(sig3, 64); + } v["created"] = created; v["sig"] = HexEncode(sig, 64); return v.dump(); @@ -242,15 +310,35 @@ bool VerifyDescriptor(void* ctxv, const std::string& jsonStr, if (!HexDecode(v["sig"].get(), sig, 64)) return false; unsigned char digest[32]; - DescriptorDigest(expect_pubkey, enc, meeting_node, created, digest); + DescriptorDigestV2(expect_pubkey, enc, meeting_node, created, digest); secp256k1_xonly_pubkey xpub; if (!secp256k1_xonly_pubkey_parse(ctx, &xpub, expect_pubkey)) return false; if (!secp256k1_schnorrsig_verify(ctx, sig, digest, 32, &xpub)) return false; + std::string onion; + if (v.contains("onion")) + { + if (v["onion"].is_string() && v.contains("sig3") && v["sig3"].is_string()) + { + std::string onionNorm; + std::string onionRaw = v["onion"].get(); + if (NormalizeOnionEndpoint(onionRaw, onionNorm) && onionNorm == onionRaw) + { + unsigned char sig3[64]; + unsigned char digest3[32]; + DescriptorDigestV3(expect_pubkey, enc, meeting_node, onionNorm, created, digest3); + if (HexDecode(v["sig3"].get(), sig3, 64) && + secp256k1_schnorrsig_verify(ctx, sig3, digest3, 32, &xpub)) + onion = onionNorm; + } + } + } + memcpy(out.pubkey, expect_pubkey, 32); out.enc = enc; out.meeting_node = meeting_node; + out.onion = onion; out.created = created; return true; } diff --git a/src/btfaddr.h b/src/btfaddr.h index 1addf8f..55a3ebd 100644 --- a/src/btfaddr.h +++ b/src/btfaddr.h @@ -52,15 +52,21 @@ struct Descriptor unsigned char pubkey[32]; std::string enc; // service x25519 public key (hex) for the E2E channel std::string meeting_node; // rendezvous node the service is registered at + std::string onion; // optional direct Tor hidden-service endpoint (host.onion:port) uint64_t created; }; +// Normalize and validate a direct Tor hidden-service endpoint. Only v3 onion +// names are accepted: 56 base32 chars plus ".onion", followed by ":PORT". +bool NormalizeOnionEndpoint(const std::string& in, std::string& out); + // Sign a descriptor. `ctx` is a secp256k1_context* (passed as void* to keep this // header free of secp headers); `seckey` is the 32-byte service secret key. // Returns the descriptor JSON as a string, or "" on failure. std::string SignDescriptor(void* ctx, const unsigned char seckey[32], const std::string& enc, const std::string& meeting_node, - uint64_t created); + uint64_t created, + const std::string& onion = std::string()); // Verify a descriptor is validly signed by `expect_pubkey` (the key the resolved // `.btf` address encodes). Returns true and fills `out` on success. diff --git a/src/main_gui.cpp b/src/main_gui.cpp index bef8503..239fb60 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -1,6 +1,7 @@ // Bitflash entry point -- starts node threads then runs GUI (or headless). #include "headers_core.h" +#include "nostr.h" #include "proxy.h" #include "selftest.h" #include "walletcmd.h" @@ -134,10 +135,11 @@ static void PrintUsage() printf(" /connectbtf=PEER_BTF_ADDRESS\n"); printf(" /rvrelay=HOST:PORT\n"); printf(" /announcerelay=HOST:PORT\n"); + printf(" /onionservice=HOST.onion:PORT (advertise this node's Tor hidden service)\n"); printf("\n"); printf("Network:\n"); printf(" /port=N (P2P listen port, default 8433)\n"); - printf(" /socks=HOST:PORT (SOCKS5 proxy for outbound Nostr and .btf relay dials)\n"); + printf(" /socks=HOST:PORT (SOCKS5 proxy for Nostr, .btf relay, and onion dials)\n"); printf(" /tor[=HOST:PORT] (Tor mode; default SOCKS5 proxy is 127.0.0.1:9050)\n"); printf(" /btfseed=ADDRESS:ENCHEX (extra bootstrap peer, repeatable)\n"); printf("\n"); @@ -286,6 +288,17 @@ static void ParseStartupArguments(int argc, char* argv[]) if (!announceRelay.empty()) strBtfAnnounceRelay = announceRelay; + string onionService = argval2(argc, argv, "/onionservice", "-onionservice"); + if (!onionService.empty()) + { + string err; + if (!BtfSetLocalOnionEndpoint(onionService, err)) + fprintf(stderr, "Ignoring /onionservice=%s: %s\n", onionService.c_str(), err.c_str()); + else + fprintf(stderr, "Direct onion peer endpoint advertised: %s\n", + BtfLocalOnionEndpoint().c_str()); + } + bool fTorMode = arg(argc, argv, "/tor") || arg(argc, argv, "-tor"); if (fTorMode) { diff --git a/src/net.cpp b/src/net.cpp index c24547e..ab17332 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -17,6 +17,8 @@ #include #endif #include +#include +#include #include "btfaddr.h" #include "btftunnel.h" #include "proxy.h" @@ -170,7 +172,7 @@ static const char* pszSockSite[SOCK_SITES] = { "external-ip probe", "listen socket", "inbound accept", "rendezvous dial", "rendezvous listen", "rendezvous accept", "loopback pair listener", "loopback pair app end", "loopback pair pump end", - "nostr relay", "stratum bridge" + "nostr relay", "direct onion peer", "stratum bridge" }; static string SockAccountingText() @@ -627,6 +629,7 @@ struct CachedBtfPeer { string btfAddr; string meeting; + string onion; string encHex; int64 lastSeen; // The peer's own signed descriptor, as it announced itself. Kept verbatim @@ -685,6 +688,8 @@ void LoadCachedBtfPeers(vector& out) CachedBtfPeer p; p.btfAddr = o["btf"].get(); p.meeting = o["meeting"].get(); + if (o.contains("onion") && o["onion"].is_string()) + btf::NormalizeOnionEndpoint(o["onion"].get(), p.onion); p.encHex = o["enc"].get(); p.lastSeen = o.value("seen", (int64)0); if (o.contains("desc") && o["desc"].is_string()) @@ -708,7 +713,8 @@ static CCriticalSection cs_btfPeerCache; // learned about by exchange must not cost us the ability to pass it on. static void RememberBtfPeer(const string& strBtfAddr, const string& strMeeting, const unsigned char enc_pub[32], - const string& strDesc = string()) + const string& strDesc = string(), + const string& strOnion = string()) { CRITICAL_BLOCK(cs_btfPeerCache) { @@ -716,11 +722,15 @@ static void RememberBtfPeer(const string& strBtfAddr, const string& strMeeting, LoadCachedBtfPeers(peers); string strKeepDesc = strDesc; + string strKeepOnion; + btf::NormalizeOnionEndpoint(strOnion, strKeepOnion); for (size_t i = 0; i < peers.size(); i++) if (peers[i].btfAddr == strBtfAddr) { if (strKeepDesc.empty()) strKeepDesc = peers[i].desc; + if (strKeepOnion.empty()) + strKeepOnion = peers[i].onion; peers.erase(peers.begin() + i); break; } @@ -728,6 +738,7 @@ static void RememberBtfPeer(const string& strBtfAddr, const string& strMeeting, CachedBtfPeer p; p.btfAddr = strBtfAddr; p.meeting = strMeeting; + p.onion = strKeepOnion; p.encHex = BytesToHex(enc_pub, 32); p.lastSeen = GetTime(); p.desc = strKeepDesc; @@ -742,6 +753,8 @@ static void RememberBtfPeer(const string& strBtfAddr, const string& strMeeting, nlohmann::json o; o["btf"] = q.btfAddr; o["meeting"] = q.meeting; + if (!q.onion.empty()) + o["onion"] = q.onion; o["enc"] = q.encHex; o["seen"] = q.lastSeen; if (!q.desc.empty()) @@ -830,7 +843,7 @@ static int TryBtfSeeds() foreach(const string& strRelay, relays) { if (fShutdown) return nConnected; - if (ConnectNodeBtfResolved(s.first, strRelay, enc)) + if (ConnectNodeBtfResolved(s.first, strRelay, "", enc)) { LogPrint("net", "btfseed: reached %s via %s\n", s.first.c_str(), strRelay.c_str()); @@ -868,7 +881,7 @@ void ThreadReconnectCachedBtfPeers(void* parg) if (fShutdown) return; unsigned char enc[32]; if (!HexToBytes(p.encHex, enc, 32)) continue; - if (ConnectNodeBtfResolved(p.btfAddr, p.meeting, enc)) + if (ConnectNodeBtfResolved(p.btfAddr, p.meeting, p.onion, enc)) { nConnected++; if (nConnected >= 8) break; // enough to bootstrap; the rest can wait @@ -959,14 +972,37 @@ int BtfPexAccept(const vector& vDesc) if (!HexToBytes(d.enc, enc, 32)) continue; - RememberBtfPeer(strAddr, d.meeting_node, enc, strDesc); + RememberBtfPeer(strAddr, d.meeting_node, enc, strDesc, d.onion); nKept++; } return nKept; } +static bool SplitHostPort(const string& strHostPort, string& strHost, unsigned short& nPort) +{ + size_t colon = strHostPort.rfind(':'); + if (colon == string::npos || colon == 0 || colon + 1 >= strHostPort.size()) + return false; + string port = strHostPort.substr(colon + 1); + for (size_t i = 0; i < port.size(); i++) + if (!isdigit((unsigned char)port[i])) + return false; + + errno = 0; + char* end = NULL; + long n = strtol(port.c_str(), &end, 10); + if (errno == ERANGE || end == NULL || *end != '\0' || n <= 0 || n > 65535) + return false; + + strHost = strHostPort.substr(0, colon); + nPort = (unsigned short)n; + return true; +} + static CNode* ConnectNodeBtfTail(const string& strBtfAddr, const unsigned char pk[32], - const string& strMeeting, const unsigned char enc_pub[32]) + const string& strMeeting, const string& strOnion, + const unsigned char enc_pub[32], + const string& strDesc = string()) { CAddress addr = BtfMarkerAddr(pk); CNode* pnode = FindNode(addr.ip); @@ -976,16 +1012,42 @@ static CNode* ConnectNodeBtfTail(const string& strBtfAddr, const unsigned char p return pnode; } - BtfChurnNoteDialAttempt(strBtfAddr, strMeeting); - size_t colon = strMeeting.rfind(':'); - if (colon == string::npos) + string strOnionNorm; + if (BtfSocks5ProxyEnabled() && btf::NormalizeOnionEndpoint(strOnion, strOnionNorm)) { - BtfChurnNoteDialResult(strBtfAddr, strMeeting, false); - return NULL; + string strOnionHost; + unsigned short nOnionPort = 0; + if (SplitHostPort(strOnionNorm, strOnionHost, nOnionPort)) + { + BtfChurnNoteDialAttempt(strBtfAddr, strOnionNorm); + SOCKET hOnionSocket = BtfConnectSocket(strOnionHost, nOnionPort, SOCK_ONION_PEER, 20); + if (hOnionSocket != INVALID_SOCKET) + { + if (fDebug) + LogPrint("net", "connected %s via direct onion %s\n", + strBtfAddr.c_str(), strOnionNorm.c_str()); + BtfChurnNoteDialResult(strBtfAddr, strOnionNorm, true); + RememberBtfPeer(strBtfAddr, strMeeting, enc_pub, strDesc, strOnionNorm); + + pnode = new CNode(hOnionSocket, addr, false); + pnode->strBtfAddr = strBtfAddr; + pnode->strBtfMeeting = string("onion ") + strOnionNorm; + pnode->AddRef(); + CRITICAL_BLOCK(cs_vNodes) + vNodes.push_back(pnode); + return pnode; + } + if (fDebug) + LogPrint("net", "ConnectNodeBtf: direct onion to %s at %s failed, falling back to rendezvous\n", + strBtfAddr.c_str(), strOnionNorm.c_str()); + BtfChurnNoteDialResult(strBtfAddr, strOnionNorm, false); + } } - string strHost = strMeeting.substr(0, colon); - int nPort = atoi(strMeeting.substr(colon + 1).c_str()); - if (nPort <= 0 || nPort > 65535) + + BtfChurnNoteDialAttempt(strBtfAddr, strMeeting); + string strHost; + unsigned short nPort = 0; + if (!SplitHostPort(strMeeting, strHost, nPort)) { BtfChurnNoteDialResult(strBtfAddr, strMeeting, false); return NULL; @@ -1005,7 +1067,7 @@ static CNode* ConnectNodeBtfTail(const string& strBtfAddr, const unsigned char p BtfChurnNoteDialResult(strBtfAddr, strMeeting, true); // This one answered -- worth trying first next time we start. - RememberBtfPeer(strBtfAddr, strMeeting, enc_pub); + RememberBtfPeer(strBtfAddr, strMeeting, enc_pub, strDesc, strOnionNorm); // Add node pnode = new CNode(hSocket, addr, false); @@ -1035,9 +1097,10 @@ CNode* ConnectNodeBtf(const string& strBtfAddr) LogPrint("net", "trying %s\n", strBtfAddr.c_str()); string strMeeting; + string strOnion; unsigned char enc_pub[32]; BtfChurnNoteResolveAttempt(); - if (!BtfResolve(strBtfAddr, strMeeting, enc_pub)) + if (!BtfResolve(strBtfAddr, strMeeting, enc_pub, &strOnion)) { BtfChurnNoteResolveResult(false); if (fDebug) @@ -1045,7 +1108,7 @@ CNode* ConnectNodeBtf(const string& strBtfAddr) return NULL; } BtfChurnNoteResolveResult(true); - return ConnectNodeBtfTail(strBtfAddr, pk, strMeeting, enc_pub); + return ConnectNodeBtfTail(strBtfAddr, pk, strMeeting, strOnion, enc_pub); } // Same as ConnectNodeBtf, but skips the Nostr resolve step because the caller @@ -1054,7 +1117,8 @@ CNode* ConnectNodeBtf(const string& strBtfAddr) // address). Makes zero relay connections -- only talks to the peer's // rendezvous meeting node. CNode* ConnectNodeBtfResolved(const string& strBtfAddr, const string& strMeeting, - const unsigned char enc_pub[32]) + const string& strOnion, const unsigned char enc_pub[32], + const string& strDesc) { unsigned char pk[32]; if (!btf::ParseAddress(strBtfAddr, pk)) @@ -1064,7 +1128,7 @@ CNode* ConnectNodeBtfResolved(const string& strBtfAddr, const string& strMeeting } if (BtfLocalAddress() == strBtfAddr) return NULL; // ourselves - return ConnectNodeBtfTail(strBtfAddr, pk, strMeeting, enc_pub); + return ConnectNodeBtfTail(strBtfAddr, pk, strMeeting, strOnion, enc_pub, strDesc); } // Anonymous inbound listener (this node's `.btf` hidden service). Registers at diff --git a/src/net.h b/src/net.h index feecf96..9300169 100644 --- a/src/net.h +++ b/src/net.h @@ -28,7 +28,8 @@ enum bool GetMyExternalIP(unsigned int& ipRet); CNode* ConnectNodeBtf(const string& strBtfAddr); CNode* ConnectNodeBtfResolved(const string& strBtfAddr, const string& strMeeting, - const unsigned char enc_pub[32]); + const string& strOnion, const unsigned char enc_pub[32], + const string& strDesc=string()); void ThreadBtfAccept(void* parg); void ThreadBtfConnect(void* parg); extern string strBtfConnect; diff --git a/src/nostr.cpp b/src/nostr.cpp index d286767..0c1b4b6 100644 --- a/src/nostr.cpp +++ b/src/nostr.cpp @@ -112,6 +112,8 @@ vector vBtfMeetingRelays = { }; string strBtfActiveRelay; static CCriticalSection cs_activeRelay; +static string strBtfOnionEndpoint; +static CCriticalSection cs_onionEndpoint; // Only the rendezvous we actually registered at (BtfSetActiveRelay, called from // ThreadBtfAccept once registration succeeds). Empty until then, on purpose: it @@ -133,6 +135,26 @@ void BtfSetActiveRelay(const string& relay) strBtfActiveRelay = relay; } +bool BtfSetLocalOnionEndpoint(const string& endpoint, string& errOut) +{ + string norm; + if (!btf::NormalizeOnionEndpoint(endpoint, norm)) + { + errOut = "expected v3 onion endpoint HOST.onion:PORT"; + return false; + } + CRITICAL_BLOCK(cs_onionEndpoint) + strBtfOnionEndpoint = norm; + return true; +} + +string BtfLocalOnionEndpoint() +{ + CRITICAL_BLOCK(cs_onionEndpoint) + return strBtfOnionEndpoint; + return ""; +} + // A relay this node advertises on Nostr for others to discover (/announcerelay). string strBtfAnnounceRelay; // Relays learned from other operators' Nostr announcements. @@ -370,10 +392,16 @@ std::string BtfLocalDescriptor() string meeting; CRITICAL_BLOCK(cs_activeRelay) meeting = strBtfActiveRelay; + string onion = BtfLocalOnionEndpoint(); if (meeting.empty()) - return ""; + { + if (onion.empty()) + return ""; + meeting = "rendezvous-pending"; + } return btf::SignDescriptor(g_nostrKey.ctx, g_nostrKey.seckey, - g_nostrKey.EncPubHex(), meeting, (uint64_t)GetTime()); + g_nostrKey.EncPubHex(), meeting, (uint64_t)GetTime(), + onion); } // The shared secp256k1 context, so net.cpp can verify descriptors that arrive @@ -806,17 +834,21 @@ static void PublishDescriptor(CWebSocket& ws, CNostrKey& key) // meeting_node is the rendezvous relay this node's hidden service // (ThreadBtfAccept in net.cpp) is registered at -- where clients dial us. string meeting_node = BtfActiveRelay(); + string onion = BtfLocalOnionEndpoint(); if (meeting_node.empty()) { - // Not registered anywhere yet. Publishing now would advertise a meeting - // node we can't be reached at, and peers would waste dials on it. The - // ThreadNostrSeed loop republishes on every pass, so skipping costs - // nothing but the delay until ThreadBtfAccept registers. - LogPrint("nostr", "Nostr: skipping descriptor publish, no rendezvous registered yet\n"); - return; + if (onion.empty()) + { + // Not registered anywhere yet, and no direct onion endpoint exists. + // Publishing now would advertise a meeting node we can't be reached at. + LogPrint("nostr", "Nostr: skipping descriptor publish, no rendezvous registered yet\n"); + return; + } + meeting_node = "rendezvous-pending"; } string desc = btf::SignDescriptor(key.ctx, key.seckey, key.EncPubHex(), - meeting_node, (uint64_t)GetTime()); + meeting_node, (uint64_t)GetTime(), + onion); if (desc.empty()) return; json tags = json::array({ json::array({ "d", BTF_DESC_DTAG }) }); @@ -1018,7 +1050,7 @@ static void HandlePoolAnnouncement(const json& ev) // Resolve a `.btf` address via a relay: fetch the descriptor published by that // address's key and verify it self-certifies under the decoded pubkey. static bool ResolveDescriptor(CWebSocket& ws, void* ctx, const string& btfAddr, - btf::Descriptor& out) + btf::Descriptor& out, string* rawOut = NULL) { unsigned char pk[32]; if (!btf::ParseAddress(btfAddr, pk)) @@ -1045,7 +1077,11 @@ static bool ResolveDescriptor(CWebSocket& ws, void* ctx, const string& btfAddr, const json& ev = j[2]; if (ev.contains("content") && btf::VerifyDescriptor(ctx, ev["content"].get(), pk, out)) + { + if (rawOut) + *rawOut = ev["content"].get(); return true; + } } else if (t == "EOSE") break; @@ -1057,8 +1093,10 @@ static bool ResolveDescriptor(CWebSocket& ws, void* ctx, const string& btfAddr, // public discovery relays for the owner's self-certified descriptor. Used by // ConnectNodeBtf in net.cpp. bool BtfResolve(const std::string& btfAddr, std::string& meetingHostPort, - unsigned char enc_pub[32]) + unsigned char enc_pub[32], std::string* onionHostPort) { + if (onionHostPort) + onionHostPort->clear(); if (!EnsureNostrKey()) return false; for (int i = 0; i < nNostrRelays && !fShutdown; i++) @@ -1076,10 +1114,15 @@ bool BtfResolve(const std::string& btfAddr, std::string& meetingHostPort, } lease.fOk = true; if (d.meeting_node.empty() || d.meeting_node == "rendezvous-pending") - continue; // descriptor predates the owner's relay config + { + if (!onionHostPort || d.onion.empty()) + continue; // caller needs rendezvous, or descriptor has no direct path + } if (!HexDecode(d.enc, enc_pub, 32)) continue; meetingHostPort = d.meeting_node; + if (onionHostPort) + *onionHostPort = d.onion; return true; } CATCH_PRINT_EXCEPTION("BtfResolve") @@ -1168,12 +1211,15 @@ bool BtfResolveMany(const std::vector& btfAddrs, btf::Descriptor d; if (!btf::VerifyDescriptor(g_nostrKey.ctx, ev["content"].get(), pk, d)) continue; - if (d.meeting_node.empty() || d.meeting_node == "rendezvous-pending") + if ((d.meeting_node.empty() || d.meeting_node == "rendezvous-pending") && + d.onion.empty()) continue; BtfResolvedPeer rp; if (!HexDecode(d.enc, rp.enc_pub, 32)) continue; rp.meetingHostPort = d.meeting_node; + rp.onionHostPort = d.onion; + rp.descriptor = ev["content"].get(); out[ai->second] = rp; pending.erase(ai->second); } @@ -1650,11 +1696,13 @@ static void ConnectDiscoveredBtfPeers() DialCtx* ctx = (DialCtx*)arg; string addr = ctx->addr; string meeting = ctx->rp.meetingHostPort; + string onion = ctx->rp.onionHostPort; + string desc = ctx->rp.descriptor; unsigned char enc_pub[32]; memcpy(enc_pub, ctx->rp.enc_pub, 32); delete ctx; - CNode* pnode = ConnectNodeBtfResolved(addr, meeting, enc_pub); + CNode* pnode = ConnectNodeBtfResolved(addr, meeting, onion, enc_pub, desc); CRITICAL_BLOCK(cs_btfPeerState) { if (pnode) diff --git a/src/nostr.h b/src/nostr.h index b5eb5e3..dc4288c 100644 --- a/src/nostr.h +++ b/src/nostr.h @@ -40,6 +40,13 @@ std::string BtfActiveRelay(); // Record the relay this node just registered its service at (thread-safe). void BtfSetActiveRelay(const std::string& relay); +// Optional Tor hidden-service endpoint for this node's normal P2P listener. +// Set by /onionservice=HOST.onion:PORT. The endpoint is signed into this node's +// .btf descriptor, so peers with -tor can dial it directly before falling back +// to rendezvous relays. +bool BtfSetLocalOnionEndpoint(const std::string& endpoint, std::string& errOut); +std::string BtfLocalOnionEndpoint(); + // If set (via /announcerelay=host:port), this node announces that relay on // Nostr so other nodes discover it automatically -- no manual seed-list edit. extern std::string strBtfAnnounceRelay; @@ -94,12 +101,15 @@ void* BtfSecpContext(); // self-certified descriptor and return its meeting node ("host:port") and // x25519 public key for the end-to-end channel. bool BtfResolve(const std::string& btfAddr, std::string& meetingHostPort, - unsigned char enc_pub[32]); + unsigned char enc_pub[32], + std::string* onionHostPort = NULL); // Result of resolving one .btf address via BtfResolveMany. struct BtfResolvedPeer { std::string meetingHostPort; + std::string onionHostPort; + std::string descriptor; unsigned char enc_pub[32]; }; diff --git a/src/readme.txt b/src/readme.txt index 794fe20..1e9cffc 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -43,11 +43,11 @@ Command-line options (in addition to the original ones) ------------------------------------------------------- /port=N listen on P2P port N (default 8433) /socks=HOST:PORT - route outbound Nostr and .btf rendezvous dials through a - SOCKS5 proxy, e.g. /socks=127.0.0.1:9050 for local Tor + route outbound Nostr, .btf rendezvous, and direct onion peer + dials through a SOCKS5 proxy, e.g. /socks=127.0.0.1:9050 for local Tor or /socks=[::1]:9050 for an IPv6 loopback proxy /tor[=HOST:PORT] - Tor mode for outbound Nostr and .btf rendezvous dials; + Tor mode for outbound Nostr, .btf rendezvous, and direct onion peer dials; defaults to the local Tor SOCKS5 proxy at 127.0.0.1:9050 /solomine mine without requiring a connected peer (local testing) /operator operator mode (run pool server) @@ -72,6 +72,8 @@ Command-line options (in addition to the original ones) override meeting relay list with one relay /announcerelay=HOST:PORT announce this relay on Nostr for discovery + /onionservice=HOST.onion:PORT + advertise this node's Tor hidden service in its signed .btf descriptor Roadmap diff --git a/src/selftest.cpp b/src/selftest.cpp index bcbf9d3..a0d9edd 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -6,6 +6,7 @@ // and against a temporary data directory. #include "headers_core.h" +#include "btfaddr.h" #include "bip32.h" #include "proxy.h" #include "selftest.h" @@ -1507,6 +1508,14 @@ static int RunSocks5ProxySelfTest() !BtfIsTorOnionHost("relay.example") && !BtfIsTorOnionHost(".onion"), "detects .onion hosts without accepting lookalikes") ? 0 : 1; + std::string onion; + nFail += Check(btf::NormalizeOnionEndpoint("ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCD.onion.:8433", onion) && + onion == "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcd.onion:8433", + "normalizes v3 onion peer endpoint") ? 0 : 1; + nFail += Check(!btf::NormalizeOnionEndpoint("short.onion:8433", onion) && + !btf::NormalizeOnionEndpoint("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcd.onion:0", onion) && + !btf::NormalizeOnionEndpoint("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabc1.onion:8433", onion), + "rejects malformed onion peer endpoints") ? 0 : 1; BtfClearSocks5Proxy(); nFail += Check(!BtfSocks5ProxyEnabled(), "starts disabled after clear") ? 0 : 1; diff --git a/src/sockcount.h b/src/sockcount.h index 5147f55..a63c6be 100644 --- a/src/sockcount.h +++ b/src/sockcount.h @@ -63,6 +63,7 @@ enum SOCK_PAIR_APP, // loopback pair: the app end handed to CNode SOCK_PAIR_PUMP, // loopback pair: the pump end SOCK_NOSTR, // outbound to a Nostr relay + SOCK_ONION_PEER, // direct outbound to a peer's Tor hidden service SOCK_STRATUM_BRIDGE, // local Stratum bridge listener/miner sockets SOCK_SITES }; diff --git a/src/test_btfaddr.cpp b/src/test_btfaddr.cpp index b40dcc1..8de426e 100644 --- a/src/test_btfaddr.cpp +++ b/src/test_btfaddr.cpp @@ -86,6 +86,7 @@ int main() } const std::string ENC_HEX = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; + const std::string ONION = "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcd.onion:8433"; // helper: fresh keypair -> (seckey, xonly pubkey) auto newKey = [&](unsigned char sk[32], unsigned char pk[32]) { @@ -108,6 +109,17 @@ int main() CHECK(!btf::VerifyDescriptor(ctx, d, pk2, bad), "different key rejected"); } + printf("descriptor_signs_optional_onion_endpoint\n"); + { + unsigned char sk[32], pk[32]; newKey(sk, pk); + std::string d = btf::SignDescriptor(ctx, sk, ENC_HEX, "meeting-node-abc", 1000, ONION); + CHECK(!d.empty(), "sign produced onion descriptor"); + btf::Descriptor got; + CHECK(btf::VerifyDescriptor(ctx, d, pk, got), "onion descriptor verifies"); + CHECK(got.meeting_node == "meeting-node-abc" && got.enc == ENC_HEX && + got.created == 1000 && got.onion == ONION, "onion fields match"); + } + printf("descriptor_rejects_tampering\n"); { unsigned char sk[32], pk[32]; newKey(sk, pk); @@ -117,6 +129,16 @@ int main() CHECK(!btf::VerifyDescriptor(ctx, j.dump(), pk, o), "tampered meeting_node rejected"); json j2 = json::parse(d); j2["enc"] = "00000000000000000000000000000000000000000000000000000000deadbeef"; CHECK(!btf::VerifyDescriptor(ctx, j2.dump(), pk, o), "tampered enc rejected"); + + std::string d3 = btf::SignDescriptor(ctx, sk, ENC_HEX, "node-1", 5, ONION); + json j3 = json::parse(d3); + j3["onion"] = "bcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcde.onion:8433"; + CHECK(btf::VerifyDescriptor(ctx, j3.dump(), pk, o) && o.onion.empty(), + "tampered onion ignored rather than trusted"); + json j4 = json::parse(d3); + j4["sig3"] = std::string(128, '0'); + CHECK(btf::VerifyDescriptor(ctx, j4.dump(), pk, o) && o.onion.empty(), + "bad onion signature ignored rather than trusted"); } printf("full_resolve_chain_from_address\n"); -- cgit v1.2.3 From 53cc17d621f55b8904812fae5df90ef089bf5aaf Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Thu, 13 Aug 2026 11:54:35 -0300 Subject: net: add managed Tor hidden service bootstrap --- README.md | 13 +- docs/rendezvous.md | 8 +- docs/tor.md | 52 +++++- src/Makefile | 5 +- src/main_gui.cpp | 65 ++++--- src/makefile.mingw | 5 +- src/net.cpp | 2 + src/readme.txt | 3 + src/selftest.cpp | 37 +++- src/tor.cpp | 492 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/tor.h | 22 +++ 11 files changed, 665 insertions(+), 39 deletions(-) create mode 100644 src/tor.cpp create mode 100644 src/tor.h (limited to 'src/selftest.cpp') diff --git a/README.md b/README.md index 181b6e8..a7ce2b2 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,7 @@ Other options worth knowing: -port=N # P2P listen port, default 8433 -socks=HOST:PORT # SOCKS5 for outbound Nostr, .btf relay, and onion peer dials -tor[=HOST:PORT] # Tor mode; default local Tor SOCKS5 proxy is 127.0.0.1:9050 +-managedtor[=PATH] # start Tor, create a hidden service, advertise its onion -onionservice=HOST.onion:PORT # advertise this node's Tor hidden service -debug # verbose log; without it debug.log is nearly silent -help # full list @@ -357,10 +358,14 @@ proxy. IPv6 proxy endpoints use brackets, for example `-socks=[::1]:9050`. `-tor` is a privacy shorthand for the usual local Tor SOCKS5 listener at `127.0.0.1:9050`. It routes outbound Nostr discovery, `.btf` rendezvous dials, and signed direct `.onion` peer dials through Tor, and keeps external-IP probes -disabled. Use `-tor=HOST:PORT` when Tor listens somewhere else. To make your own -node reachable without a rendezvous relay, expose port `8433` as a Tor hidden -service and pass `-onionservice=HOST.onion:8433`; peers with `-tor` will try it -before falling back to rendezvous. +disabled. Use `-tor=HOST:PORT` when Tor listens somewhere else. + +`-managedtor[=PATH]` starts a Tor process for this node, writes a local `torrc`, +creates a v3 hidden service for port `8433`, routes outbound discovery through +that Tor instance, and signs the generated `.onion:8433` endpoint into the +node's `.btf` descriptor. Without `PATH`, Bitflash looks for `tor/tor.exe`, +`tor.exe`, or `tor` beside the binary and then on `PATH`. The hidden-service key +is stored under the Bitflash data directory in `managed-tor/onion-service/`. ### When something looks wrong diff --git a/docs/rendezvous.md b/docs/rendezvous.md index e1888b9..68b5df3 100644 --- a/docs/rendezvous.md +++ b/docs/rendezvous.md @@ -308,9 +308,11 @@ tunnel. unreachable, discovery stalls until one is reachable again. Relay TLS is verified, which closes a man in the middle path on discovery, but availability still rests on the relay set. -- **Direct onion is not automatic Tor configuration.** Bitflash signs and - advertises a hidden-service endpoint, and Tor-mode peers can dial it. The Tor - daemon still owns hidden-service creation, private keys, rotation and uptime. +- **Direct onion needs Tor.** With `-managedtor`, Bitflash starts a Tor process, + writes a local hidden-service config, and advertises the generated onion + endpoint. With `-onionservice`, an operator can still run Tor manually and + provide the endpoint. In both cases Tor owns the hidden-service private key and + uptime. ## 11. Wire reference diff --git a/docs/tor.md b/docs/tor.md index b4f88f9..1c0f589 100644 --- a/docs/tor.md +++ b/docs/tor.md @@ -36,6 +36,47 @@ If both `-tor` and `-socks` are present, Tor mode wins and the node prints a warning. That keeps a command line with `-tor` from silently becoming a custom proxy route. +## Managed Tor mode + +`-managedtor` starts a Tor process for this node, writes a local `torrc`, creates +a v3 hidden service for the normal Bitflash P2P listener, routes outbound +discovery through that Tor instance, and signs the generated `.onion:8433` +endpoint into the node's `.btf` descriptor. + +```bash +bitflash -managedtor +``` + +Without a value, Bitflash looks for Tor in this order: + +1. `tor/tor.exe` or `tor/tor` beside the Bitflash binary; +2. `tor.exe` or `tor` beside the Bitflash binary; +3. `tor.exe` or `tor` on `PATH`. + +To point at a specific Tor executable: + +```bash +bitflash -managedtor=/opt/tor/bin/tor +bitflash -managedtor=C:\Tor Browser\Browser\TorBrowser\Tor\tor.exe +``` + +Managed Tor writes its state under the Bitflash data directory: + +```text +managed-tor/ + torrc + data/ + onion-service/ +``` + +The private key for the onion address lives in `managed-tor/onion-service/`. +Back up that directory if you want the same onion address after moving the node. +Delete it only if you intentionally want a fresh onion endpoint. + +`-managedtor` takes precedence over `-tor` and `-socks`, because it must choose +the local SOCKS port it starts. Diagnostics report the current managed Tor +state, including the SOCKS port and onion endpoint once Tor has generated it. + When Tor mode is enabled: - outbound Nostr relay dials use Tor; @@ -100,10 +141,10 @@ bitflash -operator -tor \ -announcerelay=exampleexampleexampleexampleexampleexampleexampleexample.onion:8434 ``` -## Direct full-node hidden service +## Manual direct full-node hidden service -The legacy P2P listener still binds to port `8433`. You can expose it through -Tor manually: +Managed Tor is the preferred path for ordinary nodes. Operators can still expose +the P2P listener through a separately managed Tor service: ```text HiddenServiceDir /var/lib/tor/bitflash-node/ @@ -139,6 +180,5 @@ This means rendezvous relays stop being the only way to reach a node that has published a hidden service. They remain useful bootstrap/fallback infrastructure, but an already-discovered onion-capable peer can be reached directly over Tor. -The node does not create or rotate Tor hidden services itself. Tor owns the -private key under `HiddenServiceDir`; back it up if you want the onion name to -stay stable. +Tor owns the private key under `HiddenServiceDir`; back it up if you want the +onion name to stay stable. diff --git a/src/Makefile b/src/Makefile index 511fe79..19ad33a 100644 --- a/src/Makefile +++ b/src/Makefile @@ -53,7 +53,7 @@ LIBS = -L$(SECP_LIB) -lsecp256k1 \ HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ key.h bignum.h script.h db.h base58.h net.h nostr.h \ randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h \ - selftest.h walletcmd.h compat.h sockcount.h proxy.h font_roboto.h + selftest.h walletcmd.h compat.h sockcount.h proxy.h tor.h font_roboto.h IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ obj/imgui_widgets.o obj/imgui_impl_glfw.o obj/imgui_impl_opengl3.o @@ -61,7 +61,7 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ - obj/selftest.o obj/bip32.o obj/walletcmd.o obj/proxy.o + obj/selftest.o obj/bip32.o obj/walletcmd.o obj/proxy.o obj/tor.o GUI_OBJS = obj/gui.o obj/main_gui.o @@ -194,5 +194,6 @@ tests: test_bip32 bitflash-node ./bitflash-node -selftest=pool-stratum ./bitflash-node -selftest=parse-money ./bitflash-node -selftest=socks5-proxy + ./bitflash-node -selftest=managed-tor .PHONY: tests fuzz-net-message-smoke fuzz-script-smoke diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 239fb60..deed471 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -4,6 +4,7 @@ #include "nostr.h" #include "proxy.h" #include "selftest.h" +#include "tor.h" #include "walletcmd.h" #include // hardware_concurrency, to sanity-check /genproclimit #ifndef _WIN32 @@ -113,7 +114,7 @@ static void PrintUsage() printf(" /gen\n"); printf(" /nogui or /daemon\n"); printf(" /selftest=wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt,\n"); - printf(" net-message, consensus-limits, pool-stratum, parse-money, or socks5-proxy\n"); + printf(" net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, or managed-tor\n"); printf("\n"); printf("Mining mode:\n"); printf(" /operator\n"); @@ -141,6 +142,7 @@ static void PrintUsage() printf(" /port=N (P2P listen port, default 8433)\n"); printf(" /socks=HOST:PORT (SOCKS5 proxy for Nostr, .btf relay, and onion dials)\n"); printf(" /tor[=HOST:PORT] (Tor mode; default SOCKS5 proxy is 127.0.0.1:9050)\n"); + printf(" /managedtor[=PATH] (start Tor, create a hidden service, advertise its onion)\n"); printf(" /btfseed=ADDRESS:ENCHEX (extra bootstrap peer, repeatable)\n"); printf("\n"); printf("Wallet:\n"); @@ -284,6 +286,24 @@ static void ParseStartupArguments(int argc, char* argv[]) vBtfMeetingRelays.push_back(rvRelay); } + // net.cpp has described nListenPort as "tunable via /port" since it was + // written, but nothing ever read the option, so the port was fixed at 8433 + // and a second node could not start on a machine already running one. Parse + // it before Tor setup so a managed hidden service maps the real listener. + string strPort = argval2(argc, argv, "/port", "-port"); + if (!strPort.empty()) + { + int nPort = atoi(strPort.c_str()); + if (nPort <= 0 || nPort > 65535) + fprintf(stderr, "Ignoring /port=%s: not a port number\n", strPort.c_str()); + else + { + nListenPort = htons((unsigned short)nPort); + // Or we would announce a port we never bound. + addrLocalHost.port = nListenPort; + } + } + string announceRelay = argval2(argc, argv, "/announcerelay", "-announcerelay"); if (!announceRelay.empty()) strBtfAnnounceRelay = announceRelay; @@ -299,10 +319,23 @@ static void ParseStartupArguments(int argc, char* argv[]) BtfLocalOnionEndpoint().c_str()); } + bool fManagedTor = arg(argc, argv, "/managedtor") || arg(argc, argv, "-managedtor"); bool fTorMode = arg(argc, argv, "/tor") || arg(argc, argv, "-tor"); - if (fTorMode) + string socksProxy = argval2(argc, argv, "/socks", "-socks"); + if (fManagedTor) + { + if (fTorMode || !socksProxy.empty()) + fprintf(stderr, "Warning: /managedtor takes precedence over /tor and /socks\n"); + + string torPath = argval2(argc, argv, "/managedtor", "-managedtor"); + string err; + if (!BtfStartManagedTor(torPath, err)) + fprintf(stderr, "Ignoring /managedtor=%s: %s\n", torPath.c_str(), err.c_str()); + else + fprintf(stderr, "Managed Tor enabled: %s\n", BtfManagedTorStatus().c_str()); + } + else if (fTorMode) { - string socksProxy = argval2(argc, argv, "/socks", "-socks"); if (!socksProxy.empty()) fprintf(stderr, "Warning: /tor takes precedence over /socks=%s\n", socksProxy.c_str()); @@ -317,7 +350,6 @@ static void ParseStartupArguments(int argc, char* argv[]) } else { - string socksProxy = argval2(argc, argv, "/socks", "-socks"); if (!socksProxy.empty()) { string err; @@ -348,22 +380,6 @@ static void ParseStartupArguments(int argc, char* argv[]) vBtfExtraSeeds.push_back(make_pair(val.substr(0, colon), val.substr(colon + 1))); } - // net.cpp has described nListenPort as "tunable via /port" since it was - // written, but nothing ever read the option, so the port was fixed at 8433 - // and a second node could not start on a machine already running one. - string strPort = argval2(argc, argv, "/port", "-port"); - if (!strPort.empty()) - { - int nPort = atoi(strPort.c_str()); - if (nPort <= 0 || nPort > 65535) - fprintf(stderr, "Ignoring /port=%s: not a port number\n", strPort.c_str()); - else - { - nListenPort = htons((unsigned short)nPort); - // Or we would announce a port we never bound. - addrLocalHost.port = nListenPort; - } - } } // Report a startup failure somewhere the user will actually see it. @@ -715,7 +731,12 @@ int main(int argc, char* argv[]) ReacceptWalletTransactions(); - if (!StartNode(strErrors)) { fprintf(stderr,"StartNode: %s\n",strErrors.c_str()); return 1; } + if (!StartNode(strErrors)) + { + fprintf(stderr,"StartNode: %s\n",strErrors.c_str()); + BtfStopManagedTor(); + return 1; + } if (nMineMode == MINE_OPERATOR) { gPoolServerRunning = true; @@ -748,6 +769,7 @@ int main(int argc, char* argv[]) printf("Running headless. Ctrl-C to stop.\n"); while (!fShutdown) Sleep(500); StopNode(); + BtfStopManagedTor(); // Checkpoints, releases wallet.dat from this directory's Berkeley DB // environment, and closes it. Declared since 2009 and never once // called here, which is why a wallet.dat copied elsewhere would not @@ -767,6 +789,7 @@ int main(int argc, char* argv[]) int ret = RunGUI(argc, argv); fShutdown = true; StopNode(); + BtfStopManagedTor(); DBFlush(true); return ret; #endif diff --git a/src/makefile.mingw b/src/makefile.mingw index f82247a..333be96 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -37,7 +37,7 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ - obj/selftest.o obj/bip32.o obj/walletcmd.o obj/proxy.o + obj/selftest.o obj/bip32.o obj/walletcmd.o obj/proxy.o obj/tor.o GUI_OBJS = obj/gui.o obj/main_gui.o obj/bitflash_res.o @@ -51,7 +51,7 @@ GUI_OBJS = obj/gui.o obj/main_gui.o obj/bitflash_res.o HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ key.h bignum.h script.h db.h base58.h net.h nostr.h \ randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h \ - selftest.h walletcmd.h sockcount.h proxy.h compat.h font_roboto.h + selftest.h walletcmd.h sockcount.h proxy.h tor.h compat.h font_roboto.h all: bitflash.exe @@ -113,3 +113,4 @@ tests: test_bip32.exe bitflash.exe ./bitflash.exe -selftest=consensus-limits ./bitflash.exe -selftest=pool-stratum ./bitflash.exe -selftest=socks5-proxy + ./bitflash.exe -selftest=managed-tor diff --git a/src/net.cpp b/src/net.cpp index ab17332..6bc3c1c 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -22,6 +22,7 @@ #include "btfaddr.h" #include "btftunnel.h" #include "proxy.h" +#include "tor.h" void ThreadReconnectCachedBtfPeers(void* parg); void ThreadMessageHandler2(void* parg); @@ -296,6 +297,7 @@ string GetDiagnosticsText() str += strprintf(" peers watched %d of %d held by poll%s\n", nPeersWatched, nHeld, nHeld > nPeersWatched ? " <-- NOT ALL PEERS ARE BEING READ" : ""); + str += strprintf(" managed Tor %s\n", BtfManagedTorStatus().c_str()); if (nBlocksReceived > 0) str += strprintf(" blocks received %lld (%lld arrived without a parent, %.1f%%)\n", diff --git a/src/readme.txt b/src/readme.txt index 1e9cffc..9ddbc24 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -49,6 +49,9 @@ Command-line options (in addition to the original ones) /tor[=HOST:PORT] Tor mode for outbound Nostr, .btf rendezvous, and direct onion peer dials; defaults to the local Tor SOCKS5 proxy at 127.0.0.1:9050 + /managedtor[=PATH] + start Tor, create a v3 hidden service for this node, route outbound + discovery through it, and advertise the generated onion endpoint /solomine mine without requiring a connected peer (local testing) /operator operator mode (run pool server) /participant=ADDR diff --git a/src/selftest.cpp b/src/selftest.cpp index a0d9edd..4cb872e 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -11,6 +11,7 @@ #include "proxy.h" #include "selftest.h" #include "sockcount.h" +#include "tor.h" #include "walletcmd.h" extern int RunPoolStratumSelfTest(); @@ -1565,6 +1566,38 @@ static int RunSocks5ProxySelfTest() return nFail == 0 ? 0 : 1; } +static int RunManagedTorSelfTest() +{ + printf("managed-tor self-test\n"); + int nFail = 0; + + std::string torrc = BtfBuildManagedTorrcForTest("C:/Bitflash Managed Tor/data", + "C:/Bitflash Managed Tor/onion-service", + 19050, 8433); + nFail += Check(torrc.find("DataDirectory \"C:/Bitflash Managed Tor/data\"") != std::string::npos, + "quotes the Tor data directory") ? 0 : 1; + nFail += Check(torrc.find("SocksPort 127.0.0.1:19050") != std::string::npos, + "binds SOCKS5 to localhost only") ? 0 : 1; + nFail += Check(torrc.find("IsolateSOCKSAuth") != std::string::npos && + torrc.find("IsolateClientAddr") != std::string::npos && + torrc.find("IsolateDestAddr") != std::string::npos && + torrc.find("IsolateDestPort") != std::string::npos, + "enables strict Tor stream isolation") ? 0 : 1; + nFail += Check(torrc.find("HiddenServiceDir \"C:/Bitflash Managed Tor/onion-service\"") != std::string::npos, + "writes a hidden service directory") ? 0 : 1; + nFail += Check(torrc.find("HiddenServiceVersion 3") != std::string::npos, + "requests a v3 onion service") ? 0 : 1; + nFail += Check(torrc.find("HiddenServicePort 8433 127.0.0.1:8433") != std::string::npos, + "maps the onion service to the Bitflash P2P listener") ? 0 : 1; + nFail += Check(BtfManagedTorStatus() == "disabled", + "managed Tor starts disabled") ? 0 : 1; + + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + int RunSelfTest(const std::string& name) { AttachTerminal(); @@ -1589,8 +1622,10 @@ int RunSelfTest(const std::string& name) return RunParseMoneySelfTest(); if (name == "socks5-proxy") return RunSocks5ProxySelfTest(); + if (name == "managed-tor") + return RunManagedTorSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); return 1; } diff --git a/src/tor.cpp b/src/tor.cpp new file mode 100644 index 0000000..30a6f0b --- /dev/null +++ b/src/tor.cpp @@ -0,0 +1,492 @@ +// Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. + +#include "headers_core.h" +#include "tor.h" +#include "nostr.h" +#include "proxy.h" + +#include + +#ifndef _WIN32 +#include +#include +#endif + +static CCriticalSection cs_managedTor; +static bool g_fManagedTor = false; +static bool g_fManagedTorStopping = false; +static unsigned short g_managedTorSocksPort = 0; +static unsigned short g_managedTorP2PPort = 0; +static std::string g_managedTorPath; +static std::string g_managedTorDataDir; +static std::string g_managedTorHiddenServiceDir; +static std::string g_managedTorOnion; +static std::string g_managedTorStatus; +#ifdef _WIN32 +static PROCESS_INFORMATION g_managedTorProcess; +#else +static pid_t g_managedTorPid = -1; +#endif + +static std::string PathJoin(const std::string& a, const std::string& b) +{ + if (a.empty()) + return b; + char last = a[a.size() - 1]; + if (last == '/' || last == '\\') + return a + b; + return a + "/" + b; +} + +static std::string NormalizeTorrcPath(std::string path) +{ + for (size_t i = 0; i < path.size(); i++) + if (path[i] == '\\') + path[i] = '/'; + return path; +} + +static std::string QuoteTorrcPath(const std::string& path) +{ + std::string out = "\""; + for (size_t i = 0; i < path.size(); i++) + { + if (path[i] == '"' || path[i] == '\\') + out += '\\'; + out += path[i]; + } + out += "\""; + return out; +} + +static std::string QuoteCommandArg(const std::string& arg) +{ + std::string out = "\""; + for (size_t i = 0; i < arg.size(); i++) + { + if (arg[i] == '"') + out += '\\'; + out += arg[i]; + } + out += "\""; + return out; +} + +static bool EnsureDir(const std::string& path, std::string& errOut) +{ + if (path.empty()) + { + errOut = "directory path is empty"; + return false; + } +#ifdef _WIN32 + DWORD attr = GetFileAttributesA(path.c_str()); + if (attr != INVALID_FILE_ATTRIBUTES) + { + if (attr & FILE_ATTRIBUTE_DIRECTORY) + return true; + errOut = strprintf("%s exists and is not a directory", path.c_str()); + return false; + } + if (CreateDirectoryA(path.c_str(), NULL)) + return true; + DWORD err = GetLastError(); + if (err == ERROR_ALREADY_EXISTS) + return true; + errOut = strprintf("could not create %s (error %lu)", path.c_str(), (unsigned long)err); + return false; +#else + struct stat st; + if (stat(path.c_str(), &st) == 0) + { + if (S_ISDIR(st.st_mode)) + return true; + errOut = strprintf("%s exists and is not a directory", path.c_str()); + return false; + } + if (mkdir(path.c_str(), 0700) == 0 || errno == EEXIST) + return true; + errOut = strprintf("could not create %s: %s", path.c_str(), strerror(errno)); + return false; +#endif +} + +static bool WriteTextFile(const std::string& path, const std::string& data, + std::string& errOut) +{ + FILE* f = fopen(path.c_str(), "wb"); + if (!f) + { + errOut = strprintf("could not write %s", path.c_str()); + return false; + } + bool ok = fwrite(data.data(), 1, data.size(), f) == data.size(); + if (fclose(f) != 0) + ok = false; + if (!ok) + { + errOut = strprintf("could not finish writing %s", path.c_str()); + return false; + } + return true; +} + +static bool ReadOneLine(const std::string& path, std::string& out) +{ + out.clear(); + FILE* f = fopen(path.c_str(), "rb"); + if (!f) + return false; + char buf[256] = {0}; + if (!fgets(buf, sizeof(buf), f)) + { + fclose(f); + return false; + } + fclose(f); + out = buf; + while (!out.empty() && (out[out.size() - 1] == '\n' || out[out.size() - 1] == '\r' || + out[out.size() - 1] == ' ' || out[out.size() - 1] == '\t')) + out.erase(out.size() - 1); + return !out.empty(); +} + +static bool PickLocalPort(unsigned short& portOut, std::string& errOut) +{ + portOut = 0; + SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (s == INVALID_SOCKET) + { + errOut = "could not create local port probe socket"; + return false; + } + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; + if (bind(s, (struct sockaddr*)&addr, sizeof(addr)) != 0) + { + closesocket(s); + errOut = "could not bind local port probe socket"; + return false; + } + socklen_t len = sizeof(addr); + if (getsockname(s, (struct sockaddr*)&addr, &len) != 0) + { + closesocket(s); + errOut = "could not read local port probe socket"; + return false; + } + portOut = ntohs(addr.sin_port); + closesocket(s); + if (portOut == 0) + { + errOut = "local port probe returned port zero"; + return false; + } + return true; +} + +static std::string ExecutableDir() +{ +#ifdef _WIN32 + char path[MAX_PATH + 1] = {0}; + DWORD n = GetModuleFileNameA(NULL, path, MAX_PATH); + if (n == 0 || n >= MAX_PATH) + return "."; + std::string s = path; + size_t slash = s.find_last_of("\\/"); + if (slash == std::string::npos) + return "."; + return s.substr(0, slash); +#else + char path[4096] = {0}; + ssize_t n = readlink("/proc/self/exe", path, sizeof(path) - 1); + if (n <= 0) + return "."; + path[n] = 0; + std::string s = path; + size_t slash = s.find_last_of('/'); + if (slash == std::string::npos) + return "."; + return s.substr(0, slash); +#endif +} + +static bool FileIsExecutableCandidate(const std::string& path) +{ +#ifdef _WIN32 + DWORD attr = GetFileAttributesA(path.c_str()); + return attr != INVALID_FILE_ATTRIBUTES && !(attr & FILE_ATTRIBUTE_DIRECTORY); +#else + return access(path.c_str(), X_OK) == 0; +#endif +} + +static std::string ResolveTorPath(const std::string& torPathOpt) +{ + if (!torPathOpt.empty()) + return torPathOpt; + + std::vector candidates; + std::string exeDir = ExecutableDir(); +#ifdef _WIN32 + candidates.push_back(PathJoin(exeDir, "tor/tor.exe")); + candidates.push_back(PathJoin(exeDir, "tor.exe")); + candidates.push_back("tor.exe"); +#else + candidates.push_back(PathJoin(exeDir, "tor/tor")); + candidates.push_back(PathJoin(exeDir, "tor")); + candidates.push_back("tor"); +#endif + for (size_t i = 0; i < candidates.size(); i++) + { + if (candidates[i].find('/') == std::string::npos && + candidates[i].find('\\') == std::string::npos) + return candidates[i]; // Let the OS search PATH. + if (FileIsExecutableCandidate(candidates[i])) + return candidates[i]; + } +#ifdef _WIN32 + return "tor.exe"; +#else + return "tor"; +#endif +} + +std::string BtfBuildManagedTorrcForTest(const std::string& dataDir, + const std::string& hiddenServiceDir, + unsigned short socksPort, + unsigned short p2pPort) +{ + std::string torData = NormalizeTorrcPath(dataDir); + std::string hsDir = NormalizeTorrcPath(hiddenServiceDir); + std::string logFile = NormalizeTorrcPath(PathJoin(dataDir, "tor.log")); + + std::string s; + s += "# Generated by Bitflash. Edit only while Bitflash is not managing Tor.\n"; + s += "DataDirectory " + QuoteTorrcPath(torData) + "\n"; + s += strprintf("SocksPort 127.0.0.1:%u IsolateSOCKSAuth IsolateClientAddr IsolateDestAddr IsolateDestPort\n", + (unsigned)socksPort); + s += "HiddenServiceDir " + QuoteTorrcPath(hsDir) + "\n"; + s += "HiddenServiceVersion 3\n"; + s += strprintf("HiddenServicePort %u 127.0.0.1:%u\n", + (unsigned)p2pPort, (unsigned)p2pPort); + s += "Log notice file " + QuoteTorrcPath(logFile) + "\n"; + return s; +} + +static bool StartTorProcess(const std::string& torPath, const std::string& torrcPath, + std::string& errOut) +{ +#ifdef _WIN32 + memset(&g_managedTorProcess, 0, sizeof(g_managedTorProcess)); + STARTUPINFOA si; + memset(&si, 0, sizeof(si)); + si.cb = sizeof(si); + std::string cmd = QuoteCommandArg(torPath) + " -f " + QuoteCommandArg(torrcPath); + std::vector mutableCmd(cmd.begin(), cmd.end()); + mutableCmd.push_back(0); + if (!CreateProcessA(NULL, &mutableCmd[0], NULL, NULL, FALSE, CREATE_NO_WINDOW, + NULL, NULL, &si, &g_managedTorProcess)) + { + errOut = strprintf("could not start Tor process %s (error %lu)", + torPath.c_str(), (unsigned long)GetLastError()); + return false; + } + return true; +#else + g_managedTorPid = fork(); + if (g_managedTorPid < 0) + { + errOut = strprintf("could not fork Tor process: %s", strerror(errno)); + return false; + } + if (g_managedTorPid == 0) + { + execlp(torPath.c_str(), torPath.c_str(), "-f", torrcPath.c_str(), (char*)NULL); + _exit(127); + } + return true; +#endif +} + +static void ThreadManagedTorHostname(void*) +{ + std::string hostnamePath; + unsigned short p2pPort = 0; + CRITICAL_BLOCK(cs_managedTor) + { + hostnamePath = PathJoin(g_managedTorHiddenServiceDir, "hostname"); + p2pPort = g_managedTorP2PPort; + } + + for (int i = 0; i < 90 && !fShutdown; i++) + { + std::string onion; + if (ReadOneLine(hostnamePath, onion)) + { + std::string endpoint = strprintf("%s:%u", onion.c_str(), (unsigned)p2pPort); + std::string err; + if (BtfSetLocalOnionEndpoint(endpoint, err)) + { + CRITICAL_BLOCK(cs_managedTor) + { + g_managedTorOnion = BtfLocalOnionEndpoint(); + g_managedTorStatus = strprintf("running, SOCKS5 127.0.0.1:%u, onion %s", + (unsigned)g_managedTorSocksPort, + g_managedTorOnion.c_str()); + } + printf("Managed Tor hidden service ready: %s\n", BtfLocalOnionEndpoint().c_str()); + return; + } + CRITICAL_BLOCK(cs_managedTor) + g_managedTorStatus = strprintf("running, but generated onion endpoint was rejected: %s", + err.c_str()); + return; + } + Sleep(1000); + } + + CRITICAL_BLOCK(cs_managedTor) + { + if (!g_fManagedTorStopping) + g_managedTorStatus = strprintf("running, SOCKS5 127.0.0.1:%u, waiting for hidden service hostname", + (unsigned)g_managedTorSocksPort); + } +} + +bool BtfStartManagedTor(const std::string& torPathOpt, std::string& errOut) +{ + CRITICAL_BLOCK(cs_managedTor) + { + if (g_fManagedTor) + { + errOut = "managed Tor is already running"; + return false; + } + } + + std::string root = PathJoin(GetAppDir(), "managed-tor"); + std::string dataDir = PathJoin(root, "data"); + std::string hsDir = PathJoin(root, "onion-service"); + std::string torrcPath = PathJoin(root, "torrc"); + if (!EnsureDir(root, errOut) || !EnsureDir(dataDir, errOut) || !EnsureDir(hsDir, errOut)) + return false; + + unsigned short socksPort = 0; + if (!PickLocalPort(socksPort, errOut)) + return false; + + unsigned short p2pPort = ntohs(nListenPort); + std::string torrc = BtfBuildManagedTorrcForTest(dataDir, hsDir, socksPort, p2pPort); + if (!WriteTextFile(torrcPath, torrc, errOut)) + return false; + + std::string torPath = ResolveTorPath(torPathOpt); + { + CRITICAL_BLOCK(cs_managedTor) + { + g_fManagedTor = true; + g_fManagedTorStopping = false; + g_managedTorPath = torPath; + g_managedTorDataDir = dataDir; + g_managedTorHiddenServiceDir = hsDir; + g_managedTorSocksPort = socksPort; + g_managedTorP2PPort = p2pPort; + g_managedTorOnion.clear(); + g_managedTorStatus = strprintf("starting %s, SOCKS5 127.0.0.1:%u", + torPath.c_str(), (unsigned)socksPort); + } + } + + if (!StartTorProcess(torPath, torrcPath, errOut)) + { + CRITICAL_BLOCK(cs_managedTor) + { + g_fManagedTor = false; + g_managedTorStatus = strprintf("failed: %s", errOut.c_str()); + } + return false; + } + + std::string proxyErr; + if (!BtfEnableTorProxy(strprintf("127.0.0.1:%u", (unsigned)socksPort), proxyErr)) + { + BtfStopManagedTor(); + errOut = strprintf("could not enable managed Tor SOCKS5 proxy: %s", proxyErr.c_str()); + return false; + } + + if (_beginthread(ThreadManagedTorHostname, 0, NULL) == (uintptr_t)-1) + { + BtfStopManagedTor(); + errOut = "could not start managed Tor hostname watcher"; + return false; + } + + return true; +} + +void BtfStopManagedTor() +{ + bool fWasRunning = false; + CRITICAL_BLOCK(cs_managedTor) + { + fWasRunning = g_fManagedTor; + if (!fWasRunning) + return; + g_fManagedTorStopping = true; + g_fManagedTor = false; + g_managedTorStatus = "stopping"; + } +#ifdef _WIN32 + if (g_managedTorProcess.hProcess) + { + TerminateProcess(g_managedTorProcess.hProcess, 0); + WaitForSingleObject(g_managedTorProcess.hProcess, 5000); + CloseHandle(g_managedTorProcess.hProcess); + CloseHandle(g_managedTorProcess.hThread); + memset(&g_managedTorProcess, 0, sizeof(g_managedTorProcess)); + } +#else + if (g_managedTorPid > 0) + { + kill(g_managedTorPid, SIGTERM); + for (int i = 0; i < 50; i++) + { + int status = 0; + pid_t r = waitpid(g_managedTorPid, &status, WNOHANG); + if (r == g_managedTorPid) + break; + Sleep(100); + } + g_managedTorPid = -1; + } +#endif + CRITICAL_BLOCK(cs_managedTor) + { + g_managedTorStatus = "stopped"; + g_managedTorOnion.clear(); + } +} + +bool BtfManagedTorEnabled() +{ + CRITICAL_BLOCK(cs_managedTor) + return g_fManagedTor; + return false; +} + +std::string BtfManagedTorStatus() +{ + CRITICAL_BLOCK(cs_managedTor) + { + if (!g_fManagedTor && g_managedTorStatus.empty()) + return "disabled"; + return g_managedTorStatus; + } + return "disabled"; +} diff --git a/src/tor.h b/src/tor.h new file mode 100644 index 0000000..482e773 --- /dev/null +++ b/src/tor.h @@ -0,0 +1,22 @@ +// Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. +// +// Managed Tor process support. This is transport bootstrapping only: it does +// not touch consensus, wallet keys, mining, or the .btf identity. + +#ifndef BITFLASH_TOR_H +#define BITFLASH_TOR_H + +#include + +bool BtfStartManagedTor(const std::string& torPathOpt, std::string& errOut); +void BtfStopManagedTor(); +bool BtfManagedTorEnabled(); +std::string BtfManagedTorStatus(); + +std::string BtfBuildManagedTorrcForTest(const std::string& dataDir, + const std::string& hiddenServiceDir, + unsigned short socksPort, + unsigned short p2pPort); + +#endif -- cgit v1.2.3 From 1bf78c53404d83638177889590ce199c87af63bc Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Thu, 13 Aug 2026 14:09:58 -0300 Subject: net: stop managed Tor through control port --- src/selftest.cpp | 5 +- src/tor.cpp | 249 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- src/tor.h | 1 + 3 files changed, 248 insertions(+), 7 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/selftest.cpp b/src/selftest.cpp index 4cb872e..3c93927 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1573,11 +1573,14 @@ static int RunManagedTorSelfTest() std::string torrc = BtfBuildManagedTorrcForTest("C:/Bitflash Managed Tor/data", "C:/Bitflash Managed Tor/onion-service", - 19050, 8433); + 19050, 19051, 8433); nFail += Check(torrc.find("DataDirectory \"C:/Bitflash Managed Tor/data\"") != std::string::npos, "quotes the Tor data directory") ? 0 : 1; nFail += Check(torrc.find("SocksPort 127.0.0.1:19050") != std::string::npos, "binds SOCKS5 to localhost only") ? 0 : 1; + nFail += Check(torrc.find("ControlPort 127.0.0.1:19051") != std::string::npos && + torrc.find("CookieAuthentication 1") != std::string::npos, + "binds an authenticated Tor control port to localhost only") ? 0 : 1; nFail += Check(torrc.find("IsolateSOCKSAuth") != std::string::npos && torrc.find("IsolateClientAddr") != std::string::npos && torrc.find("IsolateDestAddr") != std::string::npos && diff --git a/src/tor.cpp b/src/tor.cpp index 180cd2b..e47ee44 100644 --- a/src/tor.cpp +++ b/src/tor.cpp @@ -17,6 +17,7 @@ static CCriticalSection cs_managedTor; static bool g_fManagedTor = false; static bool g_fManagedTorStopping = false; static unsigned short g_managedTorSocksPort = 0; +static unsigned short g_managedTorControlPort = 0; static unsigned short g_managedTorP2PPort = 0; static std::string g_managedTorPath; static std::string g_managedTorDataDir; @@ -108,8 +109,16 @@ static bool EnsureDir(const std::string& path, std::string& errOut) errOut = strprintf("%s exists and is not a directory", path.c_str()); return false; } - if (mkdir(path.c_str(), 0700) == 0 || errno == EEXIST) + if (mkdir(path.c_str(), 0700) == 0) return true; + if (errno == EEXIST) + { + if (stat(path.c_str(), &st) == 0 && S_ISDIR(st.st_mode)) + { + chmod(path.c_str(), 0700); + return true; + } + } errOut = strprintf("could not create %s: %s", path.c_str(), strerror(errno)); return false; #endif @@ -154,6 +163,205 @@ static bool WriteTextFile(const std::string& path, const std::string& data, return true; } +static bool ReadBinaryFile(const std::string& path, std::vector& data, + std::string& errOut) +{ + data.clear(); + FILE* f = fopen(path.c_str(), "rb"); + if (!f) + { + errOut = strprintf("could not read %s", path.c_str()); + return false; + } + unsigned char buf[256]; + for (;;) + { + size_t n = fread(buf, 1, sizeof(buf), f); + if (n > 0) + data.insert(data.end(), buf, buf + n); + if (n < sizeof(buf)) + { + if (ferror(f)) + { + fclose(f); + errOut = strprintf("could not finish reading %s", path.c_str()); + return false; + } + break; + } + if (data.size() > 1024) + { + fclose(f); + errOut = strprintf("%s is unexpectedly large", path.c_str()); + return false; + } + } + fclose(f); + if (data.empty()) + { + errOut = strprintf("%s is empty", path.c_str()); + return false; + } + return true; +} + +static void SetSocketTimeouts(SOCKET s, int millis) +{ +#ifdef _WIN32 + DWORD timeout = millis; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&timeout, sizeof(timeout)); + setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (const char*)&timeout, sizeof(timeout)); +#else + struct timeval tv; + tv.tv_sec = millis / 1000; + tv.tv_usec = (millis % 1000) * 1000; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); +#endif +} + +static bool SendAll(SOCKET s, const std::string& data) +{ + const char* p = data.data(); + int left = (int)data.size(); + while (left > 0) + { + int n = send(s, p, left, 0); + if (n <= 0) + return false; + p += n; + left -= n; + } + return true; +} + +static bool RecvControlLine(SOCKET s, std::string& line) +{ + line.clear(); + while (line.size() < 4096) + { + char ch = 0; + int n = recv(s, &ch, 1, 0); + if (n <= 0) + return false; + if (ch == '\n') + return true; + if (ch != '\r') + line += ch; + } + return false; +} + +static SOCKET ConnectLoopback(unsigned short port, std::string& errOut) +{ +#ifdef _WIN32 + WSADATA wsadata; + int ret = WSAStartup(MAKEWORD(2,2), &wsadata); + if (ret != NO_ERROR) + { + errOut = strprintf("could not start Winsock for Tor control shutdown (WSAStartup returned %d)", ret); + return INVALID_SOCKET; + } +#endif + SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (s == INVALID_SOCKET) + { + errOut = "could not create Tor control socket"; +#ifdef _WIN32 + WSACleanup(); +#endif + return INVALID_SOCKET; + } + SetSocketTimeouts(s, 3000); + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = htons(port); + if (connect(s, (struct sockaddr*)&addr, sizeof(addr)) != 0) + { + closesocket(s); + errOut = strprintf("could not connect to Tor control port 127.0.0.1:%u", (unsigned)port); +#ifdef _WIN32 + WSACleanup(); +#endif + return INVALID_SOCKET; + } + return s; +} + +static void CloseLoopback(SOCKET s) +{ + if (s != INVALID_SOCKET) + closesocket(s); +#ifdef _WIN32 + WSACleanup(); +#endif +} + +static bool ReadPositiveTorControlReply(SOCKET s, std::string& errOut) +{ + std::string line; + if (!RecvControlLine(s, line)) + { + errOut = "Tor control port closed before replying"; + return false; + } + if (line.size() >= 3 && line.substr(0, 3) == "250") + return true; + errOut = strprintf("Tor control command failed: %s", line.c_str()); + return false; +} + +static bool RequestManagedTorShutdown(std::string& errOut) +{ + std::string dataDir; + unsigned short controlPort = 0; + CRITICAL_BLOCK(cs_managedTor) + { + dataDir = g_managedTorDataDir; + controlPort = g_managedTorControlPort; + } + if (controlPort == 0 || dataDir.empty()) + { + errOut = "Tor control port is not available"; + return false; + } + + std::vector cookie; + if (!ReadBinaryFile(PathJoin(dataDir, "control_auth_cookie"), cookie, errOut)) + return false; + + SOCKET s = ConnectLoopback(controlPort, errOut); + if (s == INVALID_SOCKET) + return false; + + bool ok = false; + std::string auth = "AUTHENTICATE " + HexStr(cookie.begin(), cookie.end(), false) + "\r\n"; + if (!SendAll(s, auth) || !ReadPositiveTorControlReply(s, errOut)) + goto done; + + if (!SendAll(s, "SIGNAL SHUTDOWN\r\n")) + { + errOut = "could not send Tor shutdown signal"; + goto done; + } + + { + std::string reply; + if (RecvControlLine(s, reply) && !(reply.size() >= 3 && reply.substr(0, 3) == "250")) + { + errOut = strprintf("Tor rejected shutdown signal: %s", reply.c_str()); + goto done; + } + } + ok = true; + +done: + CloseLoopback(s); + return ok; +} + static bool ReadOneLine(const std::string& path, std::string& out) { out.clear(); @@ -295,6 +503,7 @@ static std::string ResolveTorPath(const std::string& torPathOpt) std::string BtfBuildManagedTorrcForTest(const std::string& dataDir, const std::string& hiddenServiceDir, unsigned short socksPort, + unsigned short controlPort, unsigned short p2pPort) { std::string torData = NormalizeTorrcPath(dataDir); @@ -304,6 +513,8 @@ std::string BtfBuildManagedTorrcForTest(const std::string& dataDir, s += "DataDirectory " + QuoteTorrcPath(torData) + "\n"; s += strprintf("SocksPort 127.0.0.1:%u IsolateSOCKSAuth IsolateClientAddr IsolateDestAddr IsolateDestPort\n", (unsigned)socksPort); + s += strprintf("ControlPort 127.0.0.1:%u\n", (unsigned)controlPort); + s += "CookieAuthentication 1\n"; s += "HiddenServiceDir " + QuoteTorrcPath(hsDir) + "\n"; s += "HiddenServiceVersion 3\n"; s += strprintf("HiddenServicePort %u 127.0.0.1:%u\n", @@ -503,9 +714,13 @@ bool BtfStartManagedTor(const std::string& torPathOpt, std::string& errOut) unsigned short socksPort = 0; if (!PickLocalPort(socksPort, errOut)) return false; + unsigned short controlPort = 0; + if (!PickLocalPort(controlPort, errOut)) + return false; unsigned short p2pPort = ntohs(nListenPort); - std::string torrc = BtfBuildManagedTorrcForTest(dataDir, hsDir, socksPort, p2pPort); + std::string torrc = BtfBuildManagedTorrcForTest(dataDir, hsDir, socksPort, + controlPort, p2pPort); if (!WriteTextFile(torrcPath, torrc, errOut)) return false; @@ -519,6 +734,7 @@ bool BtfStartManagedTor(const std::string& torPathOpt, std::string& errOut) g_managedTorDataDir = dataDir; g_managedTorHiddenServiceDir = hsDir; g_managedTorSocksPort = socksPort; + g_managedTorControlPort = controlPort; g_managedTorP2PPort = p2pPort; g_managedTorOnion.clear(); g_managedTorStatus = strprintf("starting %s, SOCKS5 127.0.0.1:%u", @@ -582,8 +798,15 @@ void BtfStopManagedTor() #ifdef _WIN32 if (g_managedTorProcess.hProcess) { - TerminateProcess(g_managedTorProcess.hProcess, 0); - WaitForSingleObject(g_managedTorProcess.hProcess, 5000); + std::string shutdownErr; + bool fGraceful = RequestManagedTorShutdown(shutdownErr); + DWORD waitResult = WaitForSingleObject(g_managedTorProcess.hProcess, + fGraceful ? 10000 : 1000); + if (waitResult != WAIT_OBJECT_0) + { + TerminateProcess(g_managedTorProcess.hProcess, 0); + WaitForSingleObject(g_managedTorProcess.hProcess, 5000); + } CloseHandle(g_managedTorProcess.hProcess); CloseHandle(g_managedTorProcess.hThread); memset(&g_managedTorProcess, 0, sizeof(g_managedTorProcess)); @@ -591,15 +814,26 @@ void BtfStopManagedTor() #else if (g_managedTorPid > 0) { - kill(g_managedTorPid, SIGTERM); - for (int i = 0; i < 50; i++) + std::string shutdownErr; + if (!RequestManagedTorShutdown(shutdownErr)) + kill(g_managedTorPid, SIGTERM); + bool fExited = false; + for (int i = 0; i < 100; i++) { int status = 0; pid_t r = waitpid(g_managedTorPid, &status, WNOHANG); if (r == g_managedTorPid) + { + fExited = true; break; + } Sleep(100); } + if (!fExited) + { + kill(g_managedTorPid, SIGKILL); + waitpid(g_managedTorPid, NULL, 0); + } g_managedTorPid = -1; } #endif @@ -607,6 +841,9 @@ void BtfStopManagedTor() { g_managedTorStatus = "stopped"; g_managedTorOnion.clear(); + g_managedTorSocksPort = 0; + g_managedTorControlPort = 0; + g_managedTorP2PPort = 0; } } diff --git a/src/tor.h b/src/tor.h index 482e773..7c5c603 100644 --- a/src/tor.h +++ b/src/tor.h @@ -17,6 +17,7 @@ std::string BtfManagedTorStatus(); std::string BtfBuildManagedTorrcForTest(const std::string& dataDir, const std::string& hiddenServiceDir, unsigned short socksPort, + unsigned short controlPort, unsigned short p2pPort); #endif -- cgit v1.2.3 From f57d58155cdf65ec2ddb1a01c9d9466bf60df9ab Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Thu, 13 Aug 2026 17:15:24 -0300 Subject: wallet: report locked encrypted recovery seed --- src/main.cpp | 3 +++ src/main.h | 2 ++ src/selftest.cpp | 44 +++++++++++++++++++++++++++++++++++++++++--- src/walletcmd.cpp | 16 +++++++++++++++- 4 files changed, 61 insertions(+), 4 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/main.cpp b/src/main.cpp index f83164f..5bd4028 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4355,6 +4355,9 @@ WalletRecoveryAudit GetWalletRecoveryAudit() CRITICAL_BLOCK(cs_keyPool) { audit.fHaveSeed = HaveHDSeed(); + audit.fSeedEncryptedLocked = !audit.fHaveSeed && + (!vchCryptedHDMaster.empty() || + !vchCryptedHDChainCode.empty()); audit.nSchema = nHDKeySchema; audit.nDerivedKnown = nHDNext; audit.nReceiveNext = nHDReceiveNext; diff --git a/src/main.h b/src/main.h index b389f1d..dc5527e 100644 --- a/src/main.h +++ b/src/main.h @@ -292,6 +292,7 @@ int64 GetBalance(); struct WalletRecoveryAudit { bool fHaveSeed; + bool fSeedEncryptedLocked; bool fDeriveComplete; int nSchema; unsigned int nDerivedKnown; @@ -311,6 +312,7 @@ struct WalletRecoveryAudit WalletRecoveryAudit() { fHaveSeed = false; + fSeedEncryptedLocked = false; fDeriveComplete = true; nSchema = HD_SCHEMA_NONE; nDerivedKnown = 0; diff --git a/src/selftest.cpp b/src/selftest.cpp index 3c93927..79feddf 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -941,7 +941,8 @@ static string QuoteCommandArg(const string& str) static int RunBitflashChild(const string& strExe, const vector& vArgs, - const string* pStdinFile = NULL) + const string* pStdinFile = NULL, + const string* pOutputFile = NULL) { #ifdef _WIN32 vector argv; @@ -954,8 +955,16 @@ static int RunBitflashChild(const string& strExe, int nOldErr = _dup(2); int nOldIn = _dup(0); int nNull = _open("NUL", _O_WRONLY); + int nOut = -1; int nIn = -1; - if (nNull >= 0) + if (pOutputFile) + nOut = _open(pOutputFile->c_str(), _O_WRONLY|_O_CREAT|_O_TRUNC|_O_BINARY, _S_IREAD|_S_IWRITE); + if (nOut >= 0) + { + _dup2(nOut, 1); + _dup2(nOut, 2); + } + else if (nNull >= 0) { _dup2(nNull, 1); _dup2(nNull, 2); @@ -969,6 +978,8 @@ static int RunBitflashChild(const string& strExe, int nRet = _spawnv(_P_WAIT, strExe.c_str(), &argv[0]); if (nIn >= 0) _close(nIn); + if (nOut >= 0) + _close(nOut); if (nNull >= 0) _close(nNull); if (nOldIn >= 0) @@ -993,7 +1004,10 @@ static int RunBitflashChild(const string& strExe, strCmd += " " + QuoteCommandArg(vArgs[i]); if (pStdinFile) strCmd += " < " + QuoteCommandArg(*pStdinFile); - strCmd += " > /dev/null 2>&1"; + if (pOutputFile) + strCmd += " > " + QuoteCommandArg(*pOutputFile) + " 2>&1"; + else + strCmd += " > /dev/null 2>&1"; return system(strCmd.c_str()); #endif } @@ -1066,6 +1080,8 @@ static int RunWalletEncryptSelfTest() string strRightDump = tmp + "/right-pass-dump.txt"; string strLiteralDump = tmp + "/literal-pass-dump.txt"; string strStdinDump = tmp + "/stdin-pass-dump.txt"; + string strLockedAudit = tmp + "/locked-recovery-audit.txt"; + string strUnlockedAudit = tmp + "/unlocked-recovery-audit.txt"; string strWrongPassFile = tmp + "/wrong-pass.txt"; string strRightPassFile = tmp + "/right-pass.txt"; nFail += Check(WriteTextFile(strWrongPassFile, "wrong-passphrase\n") && @@ -1081,6 +1097,17 @@ static int RunWalletEncryptSelfTest() nFail += Check(nWrongRet != 0 && !FileExists(strWrongDump.c_str()), "a restarted wallet rejects the wrong-passphrase") ? 0 : 1; + vector vLockedAuditArgs; + vLockedAuditArgs.push_back("-datadir=" + tmp); + vLockedAuditArgs.push_back("-recoveryaudit"); + vLockedAuditArgs.push_back("-nogui"); + int nLockedAuditRet = RunBitflashChild(strExe, vLockedAuditArgs, NULL, &strLockedAudit); + nFail += Check(nLockedAuditRet == 2 && + FileContainsText(strLockedAudit, "recovery phrase: encrypted, unlock wallet to audit") && + FileContainsText(strLockedAudit, "recovery coverage: unavailable while wallet is locked") && + FileContainsText(strLockedAudit, "Unlock the wallet with /walletpassphrase"), + "a locked encrypted wallet reports that phrase coverage needs unlock") ? 0 : 1; + vector vLiteralArgs; vLiteralArgs.push_back("-datadir=" + tmp); vLiteralArgs.push_back("-walletpassphrase=btf-test-passphrase"); @@ -1101,6 +1128,17 @@ static int RunWalletEncryptSelfTest() nFail += Check(FileContainsText(strRightDump, HexStrLocal(vchDefaultPrivBytes)), "the restarted wallet can decrypt and dump the original key") ? 0 : 1; + vector vUnlockedAuditArgs; + vUnlockedAuditArgs.push_back("-datadir=" + tmp); + vUnlockedAuditArgs.push_back("-walletpassphrase=@" + strRightPassFile); + vUnlockedAuditArgs.push_back("-recoveryaudit"); + vUnlockedAuditArgs.push_back("-nogui"); + int nUnlockedAuditRet = RunBitflashChild(strExe, vUnlockedAuditArgs, NULL, &strUnlockedAudit); + nFail += Check(nUnlockedAuditRet == 0 && + FileContainsText(strUnlockedAudit, "recovery phrase: present") && + !FileContainsText(strUnlockedAudit, "recovery phrase: not installed"), + "an unlocked encrypted wallet audits the installed recovery phrase") ? 0 : 1; + vector vStdinArgs; vStdinArgs.push_back("-datadir=" + tmp); vStdinArgs.push_back("-walletpassphrase"); diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index e8ac4bf..eccb9b3 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -400,7 +400,12 @@ int CmdRecoveryAudit() 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(" recovery phrase: present\n"); + else if (audit.fSeedEncryptedLocked) + printf(" recovery phrase: encrypted, unlock wallet to audit\n"); + else + printf(" recovery phrase: not installed\n"); if (audit.fHaveSeed) { printf(" derivation schema: %s\n", HDKeySchemaName(audit.nSchema).c_str()); @@ -412,6 +417,15 @@ int CmdRecoveryAudit() if (!audit.fDeriveComplete) printf(" derivation warning: %s\n", audit.strDeriveError.c_str()); printf(" total spendable balance: %s BTF\n", FormatMoney(nTotal).c_str()); + if (audit.fSeedEncryptedLocked) + { + printf(" recovery coverage: unavailable while wallet is locked\n"); + printf(" immature mining rewards: %s BTF\n", FormatMoney(nImmatureTotal).c_str()); + printf("\n"); + printf("Unlock the wallet with /walletpassphrase or /walletpassphrase=@FILE to audit phrase coverage.\n"); + fflush(stdout); + return 2; + } 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", -- cgit v1.2.3 From 6f9e07186d06f772c93832f0dcbd4f9432b44579 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Thu, 13 Aug 2026 17:19:37 -0300 Subject: tests: cover portable wallet copies --- src/main_gui.cpp | 3 +- src/selftest.cpp | 164 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 165 insertions(+), 2 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 3f52e91..50c4f34 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -114,7 +114,8 @@ static void PrintUsage() printf(" /gen\n"); printf(" /nogui or /daemon\n"); printf(" /selftest=wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt,\n"); - printf(" net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, or managed-tor\n"); + printf(" wallet-portability, net-message, consensus-limits, pool-stratum,\n"); + printf(" parse-money, socks5-proxy, or managed-tor\n"); printf("\n"); printf("Mining mode:\n"); printf(" /operator\n"); diff --git a/src/selftest.cpp b/src/selftest.cpp index 79feddf..3f3cb16 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -34,6 +34,7 @@ extern int RunPoolStratumSelfTest(); #undef printf #include +#include #include #include @@ -925,6 +926,46 @@ static bool WriteTextFile(const string& strPath, const string& strText) return fOk; } +static bool MakeDirLocal(const string& strPath) +{ +#ifdef _WIN32 + return CreateDirectoryA(strPath.c_str(), NULL) || GetLastError() == ERROR_ALREADY_EXISTS; +#else + return mkdir(strPath.c_str(), 0700) == 0 || errno == EEXIST; +#endif +} + +static bool CopyFileLocal(const string& strSrc, const string& strDst) +{ + FILE* in = fopen(strSrc.c_str(), "rb"); + if (!in) + return false; + FILE* out = fopen(strDst.c_str(), "wb"); + if (!out) + { + fclose(in); + return false; + } + + bool fOk = true; + char buf[65536]; + size_t n = 0; + while ((n = fread(buf, 1, sizeof(buf), in)) > 0) + { + if (fwrite(buf, 1, n, out) != n) + { + fOk = false; + break; + } + } + if (ferror(in)) + fOk = false; + if (fclose(out) != 0) + fOk = false; + fclose(in); + return fOk; +} + static string QuoteCommandArg(const string& str) { string out = "\""; @@ -1169,6 +1210,125 @@ static int RunWalletEncryptSelfTest() return nFail == 0 ? 0 : 1; } +static int RunWalletPortabilitySelfTest() +{ + fflush(stdout); + printf("wallet-portability self-test\n"); + + std::string tmp; + std::string cwd; + if (!MakeTempDir(tmp)) + { + printf(" FAIL could not create a temporary data directory\n"); + return 1; + } + if (!GetCurrentDir(cwd)) + { + printf(" FAIL could not read current directory\n"); + RemoveTree(tmp); + return 1; + } + + int nFail = 0; + printf(" temp root: %s\n", tmp.c_str()); + + try + { +#ifdef _WIN32 + string strExe = cwd + "\\bitflash.exe"; +#else + string strExe = cwd + "/bitflash-node"; +#endif + string strOriginal = tmp + "/original"; + string strRawCopy = tmp + "/raw-copy"; + string strBackupCopy = tmp + "/backup-copy"; + nFail += Check(MakeDirLocal(strOriginal) && + MakeDirLocal(strRawCopy) && + MakeDirLocal(strBackupCopy), + "temporary wallet directories can be created") ? 0 : 1; + + string strCreateOut = tmp + "/create.txt"; + vector vCreateArgs; + vCreateArgs.push_back("-datadir=" + strOriginal); + vCreateArgs.push_back("-nomanagedtor"); + vCreateArgs.push_back("-nogui"); + vCreateArgs.push_back("-newphrase"); + int nCreateRet = RunBitflashChild(strExe, vCreateArgs, NULL, &strCreateOut); + nFail += Check(nCreateRet == 0 && + FileContainsText(strCreateOut, "Write these twelve words down"), + "a phrase wallet can be created in a fresh datadir") ? 0 : 1; + + string strOriginalAudit = tmp + "/original-audit.txt"; + vector vOriginalAuditArgs; + vOriginalAuditArgs.push_back("-datadir=" + strOriginal); + vOriginalAuditArgs.push_back("-nomanagedtor"); + vOriginalAuditArgs.push_back("-nogui"); + vOriginalAuditArgs.push_back("-recoveryaudit"); + int nOriginalAuditRet = RunBitflashChild(strExe, vOriginalAuditArgs, NULL, &strOriginalAudit); + nFail += Check(nOriginalAuditRet == 0 && + FileContainsText(strOriginalAudit, "recovery phrase: present") && + FileContainsText(strOriginalAudit, "SLIP-0044 BITFLASH"), + "the original wallet reports its recovery phrase") ? 0 : 1; + + string strPortableWallet = tmp + "/portable-wallet.dat"; + string strBackupOut = tmp + "/backupwallet.txt"; + vector vBackupArgs; + vBackupArgs.push_back("-datadir=" + strOriginal); + vBackupArgs.push_back("-nomanagedtor"); + vBackupArgs.push_back("-nogui"); + vBackupArgs.push_back("-backupwallet=" + strPortableWallet); + int nBackupRet = RunBitflashChild(strExe, vBackupArgs, NULL, &strBackupOut); + nFail += Check(nBackupRet == 0 && FileExists(strPortableWallet.c_str()), + "/backupwallet writes a portable wallet.dat") ? 0 : 1; + + nFail += Check(CopyFileLocal(strOriginal + "/wallet.dat", + strRawCopy + "/wallet.dat"), + "a raw wallet.dat can be copied without database logs") ? 0 : 1; + string strRawAudit = tmp + "/raw-copy-audit.txt"; + vector vRawAuditArgs; + vRawAuditArgs.push_back("-datadir=" + strRawCopy); + vRawAuditArgs.push_back("-nomanagedtor"); + vRawAuditArgs.push_back("-nogui"); + vRawAuditArgs.push_back("-recoveryaudit"); + int nRawAuditRet = RunBitflashChild(strExe, vRawAuditArgs, NULL, &strRawAudit); + nFail += Check(nRawAuditRet == 0 && + FileContainsText(strRawAudit, "recovery phrase: present") && + !FileContainsText(strRawAudit, "Cannot open wallet.dat"), + "a raw wallet.dat copy opens in a new datadir") ? 0 : 1; + + nFail += Check(CopyFileLocal(strPortableWallet, + strBackupCopy + "/wallet.dat"), + "the portable backup can be installed as wallet.dat") ? 0 : 1; + string strBackupAudit = tmp + "/backup-copy-audit.txt"; + vector vBackupAuditArgs; + vBackupAuditArgs.push_back("-datadir=" + strBackupCopy); + vBackupAuditArgs.push_back("-nomanagedtor"); + vBackupAuditArgs.push_back("-nogui"); + vBackupAuditArgs.push_back("-recoveryaudit"); + int nBackupAuditRet = RunBitflashChild(strExe, vBackupAuditArgs, NULL, &strBackupAudit); + nFail += Check(nBackupAuditRet == 0 && + FileContainsText(strBackupAudit, "recovery phrase: present") && + FileContainsText(strBackupAudit, "SLIP-0044 BITFLASH"), + "a /backupwallet copy opens in a new datadir") ? 0 : 1; + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + RemoveTree(tmp); + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + static int RunNetMessageSelfTest() { fflush(stdout); @@ -1653,6 +1813,8 @@ int RunSelfTest(const std::string& name) return RunWalletCryptoSelfTest(); if (name == "wallet-encrypt") return RunWalletEncryptSelfTest(); + if (name == "wallet-portability") + return RunWalletPortabilitySelfTest(); if (name == "net-message") return RunNetMessageSelfTest(); if (name == "consensus-limits") @@ -1667,6 +1829,6 @@ int RunSelfTest(const std::string& name) return RunManagedTorSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); return 1; } -- cgit v1.2.3 From c0f886f0683ebd1321751355ddc0c757496320c9 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Thu, 13 Aug 2026 17:23:36 -0300 Subject: tests: assert encryptwallet purges BDB logs --- src/selftest.cpp | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) (limited to 'src/selftest.cpp') diff --git a/src/selftest.cpp b/src/selftest.cpp index 3f3cb16..8a448f0 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -966,6 +966,50 @@ static bool CopyFileLocal(const string& strSrc, const string& strDst) return fOk; } +static bool DirectoryHasFileWithPrefix(const string& strDir, const string& strPrefix) +{ +#ifdef _WIN32 + WIN32_FIND_DATAA findData; + string pattern = strDir + "\\*"; + HANDLE hFind = FindFirstFileA(pattern.c_str(), &findData); + if (hFind == INVALID_HANDLE_VALUE) + return false; + bool fFound = false; + do + { + if (!(findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) + { + string name = findData.cFileName; + if (name.compare(0, strPrefix.size(), strPrefix) == 0) + { + fFound = true; + break; + } + } + } + while (FindNextFileA(hFind, &findData)); + FindClose(hFind); + return fFound; +#else + DIR* dir = opendir(strDir.c_str()); + if (!dir) + return false; + bool fFound = false; + struct dirent* ent; + while ((ent = readdir(dir)) != NULL) + { + string name = ent->d_name; + if (name.compare(0, strPrefix.size(), strPrefix) == 0) + { + fFound = true; + break; + } + } + closedir(dir); + return fFound; +#endif +} + static string QuoteCommandArg(const string& str) { string out = "\""; @@ -1310,6 +1354,20 @@ static int RunWalletPortabilitySelfTest() FileContainsText(strBackupAudit, "recovery phrase: present") && FileContainsText(strBackupAudit, "SLIP-0044 BITFLASH"), "a /backupwallet copy opens in a new datadir") ? 0 : 1; + + string strPassFile = tmp + "/encrypt-pass.txt"; + string strEncryptOut = tmp + "/encryptwallet.txt"; + nFail += Check(WriteTextFile(strPassFile, "portable-test-passphrase\n"), + "an encryption passphrase file can be written") ? 0 : 1; + vector vEncryptArgs; + vEncryptArgs.push_back("-datadir=" + strOriginal); + vEncryptArgs.push_back("-nomanagedtor"); + vEncryptArgs.push_back("-nogui"); + vEncryptArgs.push_back("-encryptwallet=@" + strPassFile); + int nEncryptRet = RunBitflashChild(strExe, vEncryptArgs, NULL, &strEncryptOut); + nFail += Check(nEncryptRet == 0 && + !DirectoryHasFileWithPrefix(strOriginal + "/database", "log."), + "the encryptwallet command purges Berkeley DB environment logs") ? 0 : 1; } catch (const std::exception& e) { -- cgit v1.2.3 From 8e62e362e2fbe6fd6c5d66ddb354ec544f8cf0db Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Thu, 13 Aug 2026 17:36:09 -0300 Subject: wallet: add storage audit command --- src/main_gui.cpp | 9 +++ src/selftest.cpp | 32 ++++++++++ src/walletcmd.cpp | 178 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/walletcmd.h | 4 ++ 4 files changed, 223 insertions(+) (limited to 'src/selftest.cpp') diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 50c4f34..532bc3b 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -172,6 +172,8 @@ static void PrintUsage() 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(" /walletstorageaudit (count wallet.dat record types without\n"); + printf(" printing wallet values, then exit)\n"); printf(" /rescan (walk the chain for coins this wallet owns\n"); printf(" but never recorded, then exit)\n"); printf("\n"); @@ -704,6 +706,13 @@ int main(int argc, char* argv[]) return nRet; } + if (arg(argc,argv,"/walletstorageaudit") || arg(argc,argv,"-walletstorageaudit")) + { + int nRet = CmdWalletStorageAudit(); + 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 8a448f0..b112238 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1314,6 +1314,21 @@ static int RunWalletPortabilitySelfTest() FileContainsText(strOriginalAudit, "SLIP-0044 BITFLASH"), "the original wallet reports its recovery phrase") ? 0 : 1; + string strOriginalStorageAudit = tmp + "/original-storage-audit.txt"; + vector vOriginalStorageAuditArgs; + vOriginalStorageAuditArgs.push_back("-datadir=" + strOriginal); + vOriginalStorageAuditArgs.push_back("-nomanagedtor"); + vOriginalStorageAuditArgs.push_back("-nogui"); + vOriginalStorageAuditArgs.push_back("-walletstorageaudit"); + int nOriginalStorageAuditRet = + RunBitflashChild(strExe, vOriginalStorageAuditArgs, NULL, &strOriginalStorageAudit); + nFail += Check(nOriginalStorageAuditRet == 0 && + FileContainsText(strOriginalStorageAudit, "Wallet storage audit") && + FileContainsText(strOriginalStorageAudit, "plain HD seed: complete") && + FileContainsText(strOriginalStorageAudit, "encrypted HD seed: none") && + FileContainsText(strOriginalStorageAudit, "malformed records: 0"), + "the storage audit recognizes a plaintext phrase wallet") ? 0 : 1; + string strPortableWallet = tmp + "/portable-wallet.dat"; string strBackupOut = tmp + "/backupwallet.txt"; vector vBackupArgs; @@ -1368,6 +1383,23 @@ static int RunWalletPortabilitySelfTest() nFail += Check(nEncryptRet == 0 && !DirectoryHasFileWithPrefix(strOriginal + "/database", "log."), "the encryptwallet command purges Berkeley DB environment logs") ? 0 : 1; + + string strEncryptedStorageAudit = tmp + "/encrypted-storage-audit.txt"; + vector vEncryptedStorageAuditArgs; + vEncryptedStorageAuditArgs.push_back("-datadir=" + strOriginal); + vEncryptedStorageAuditArgs.push_back("-nomanagedtor"); + vEncryptedStorageAuditArgs.push_back("-nogui"); + vEncryptedStorageAuditArgs.push_back("-walletstorageaudit"); + int nEncryptedStorageAuditRet = + RunBitflashChild(strExe, vEncryptedStorageAuditArgs, NULL, &strEncryptedStorageAudit); + nFail += Check(nEncryptedStorageAuditRet == 0 && + FileContainsText(strEncryptedStorageAudit, "plain private keys: 0") && + FileContainsText(strEncryptedStorageAudit, "encrypted private keys:") && + FileContainsText(strEncryptedStorageAudit, "plain HD seed: none") && + FileContainsText(strEncryptedStorageAudit, "encrypted HD seed: complete") && + FileContainsText(strEncryptedStorageAudit, "wallet minimum version: present") && + FileContainsText(strEncryptedStorageAudit, "malformed records: 0"), + "the storage audit recognizes an encrypted wallet") ? 0 : 1; } catch (const std::exception& e) { diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index eccb9b3..896d593 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -470,6 +470,184 @@ int CmdRecoveryAudit() return 0; } +struct WalletStorageAuditCounts +{ + unsigned int nTotal; + unsigned int nMalformed; + unsigned int nUnknown; + unsigned int nVersion; + unsigned int nNames; + unsigned int nTransactions; + unsigned int nPlainKeys; + unsigned int nEncryptedKeys; + unsigned int nMasterKeys; + unsigned int nDefaultKey; + unsigned int nPlainHDMaster; + unsigned int nPlainHDChainCode; + unsigned int nCryptedHDMaster; + unsigned int nCryptedHDChainCode; + unsigned int nWalletMinVersion; + unsigned int nPool; + unsigned int nSettings; + unsigned int nHDNext; + unsigned int nHDSchema; + unsigned int nHDCoinType; + unsigned int nHDReceiveNext; + unsigned int nHDChangeNext; + + WalletStorageAuditCounts() + { + memset(this, 0, sizeof(*this)); + } +}; + +static std::string HDSeedStorageState(unsigned int nMaster, unsigned int nChain) +{ + if (nMaster == 0 && nChain == 0) + return "none"; + if (nMaster > 0 && nChain > 0) + return "complete"; + return "incomplete"; +} + +static void CountWalletStorageType(const std::string& strType, + WalletStorageAuditCounts& c) +{ + if (strType == "version") + c.nVersion++; + else if (strType == "name") + c.nNames++; + else if (strType == "tx") + c.nTransactions++; + else if (strType == "key") + c.nPlainKeys++; + else if (strType == "ckey") + c.nEncryptedKeys++; + else if (strType == "mkey") + c.nMasterKeys++; + else if (strType == "defaultkey") + c.nDefaultKey++; + else if (strType == "hdmaster") + c.nPlainHDMaster++; + else if (strType == "hdchaincode") + c.nPlainHDChainCode++; + else if (strType == "cryptedhdmaster") + c.nCryptedHDMaster++; + else if (strType == "cryptedhdchaincode") + c.nCryptedHDChainCode++; + else if (strType == "walletminversion") + c.nWalletMinVersion++; + else if (strType == "pool") + c.nPool++; + else if (strType == "setting") + c.nSettings++; + else if (strType == "hdnext") + c.nHDNext++; + else if (strType == "hdschema") + c.nHDSchema++; + else if (strType == "hdcointype") + c.nHDCoinType++; + else if (strType == "hdreceivenext") + c.nHDReceiveNext++; + else if (strType == "hdchangenext") + c.nHDChangeNext++; + else + c.nUnknown++; +} + +static bool ReadWalletStorageCounts(WalletStorageAuditCounts& counts, + std::string& strError) +{ + class CWalletAuditDB : public CWalletDB + { + public: + CWalletAuditDB() : CWalletDB("r") { } + using CDB::GetCursor; + using CDB::ReadAtCursor; + }; + + CWalletAuditDB walletdb; + Dbc* pcursor = walletdb.GetCursor(); + if (!pcursor) + { + strError = "cannot open wallet.dat cursor"; + return false; + } + + for (;;) + { + CDataStream ssKey(SER_DISK); + CDataStream ssValue(SER_DISK); + int ret = walletdb.ReadAtCursor(pcursor, ssKey, ssValue); + if (ret == DB_NOTFOUND) + break; + if (ret != 0) + { + pcursor->close(); + strError = strprintf("Berkeley DB cursor read failed (%d)", ret); + return false; + } + + counts.nTotal++; + try + { + std::string strType; + ssKey >> strType; + CountWalletStorageType(strType, counts); + } + catch (...) + { + counts.nMalformed++; + } + } + + pcursor->close(); + return true; +} + +int CmdWalletStorageAudit() +{ + AttachTerminal(); + + WalletStorageAuditCounts counts; + std::string strError; + if (!ReadWalletStorageCounts(counts, strError)) + { + fprintf(stderr, "Cannot audit wallet storage: %s\n", strError.c_str()); + return 1; + } + + printf("Wallet storage audit\n"); + printf(" records total: %u\n", counts.nTotal); + printf(" malformed records: %u\n", counts.nMalformed); + printf(" unknown records: %u\n", counts.nUnknown); + printf(" plain private keys: %u\n", counts.nPlainKeys); + printf(" encrypted private keys: %u\n", counts.nEncryptedKeys); + printf(" encryption master keys: %u\n", counts.nMasterKeys); + printf(" default public key: %s\n", counts.nDefaultKey ? "present" : "none"); + printf(" plain HD seed: %s\n", + HDSeedStorageState(counts.nPlainHDMaster, + counts.nPlainHDChainCode).c_str()); + printf(" encrypted HD seed: %s\n", + HDSeedStorageState(counts.nCryptedHDMaster, + counts.nCryptedHDChainCode).c_str()); + printf(" wallet minimum version: %s\n", + counts.nWalletMinVersion ? "present" : "none"); + printf(" keypool entries: %u\n", counts.nPool); + printf(" wallet transactions: %u\n", counts.nTransactions); + printf(" address book labels: %u\n", counts.nNames); + printf(" settings: %u\n", counts.nSettings); + printf(" HD metadata records: %u\n", + counts.nHDNext + counts.nHDSchema + counts.nHDCoinType + + counts.nHDReceiveNext + counts.nHDChangeNext); + printf(" database version records: %u\n", counts.nVersion); + fflush(stdout); + + if (counts.nMalformed > 0) + return 2; + return 0; +} + int CmdEncryptWallet(const std::string& strPassphrase) { AttachTerminal(); diff --git a/src/walletcmd.h b/src/walletcmd.h index 007b8b5..4a703db 100644 --- a/src/walletcmd.h +++ b/src/walletcmd.h @@ -62,6 +62,10 @@ int CmdShowDerived(int nCount); // by the installed recovery phrase or still depends on wallet.dat-only keys. int CmdRecoveryAudit(); +// Diagnostic: count wallet.dat record types without printing keys, +// addresses, transaction ids, labels, or other wallet values. +int CmdWalletStorageAudit(); + // Rewrite wallet.dat so private keys and the HD seed are encrypted with the // given passphrase. Returns 0 on success and exits without starting the node. int CmdEncryptWallet(const std::string& strPassphrase); -- cgit v1.2.3 From 805c5757b801d80ea575c6a8b58e2855254f9046 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Thu, 13 Aug 2026 17:40:20 -0300 Subject: wallet: export storage audit json --- src/main_gui.cpp | 10 ++++++-- src/selftest.cpp | 38 +++++++++++++++++++++++++++++ src/walletcmd.cpp | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- src/walletcmd.h | 2 +- 4 files changed, 117 insertions(+), 4 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 532bc3b..395cc0f 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -174,6 +174,8 @@ static void PrintUsage() printf(" exits 2 when wallet.dat is still needed)\n"); printf(" /walletstorageaudit (count wallet.dat record types without\n"); printf(" printing wallet values, then exit)\n"); + printf(" /walletstorageauditjson=FILE\n"); + printf(" (write the same audit as deterministic JSON)\n"); printf(" /rescan (walk the chain for coins this wallet owns\n"); printf(" but never recorded, then exit)\n"); printf("\n"); @@ -706,9 +708,13 @@ int main(int argc, char* argv[]) return nRet; } - if (arg(argc,argv,"/walletstorageaudit") || arg(argc,argv,"-walletstorageaudit")) + string strWalletStorageAuditJson = + argval2(argc, argv, "/walletstorageauditjson", "-walletstorageauditjson"); + if (arg(argc,argv,"/walletstorageaudit") || + arg(argc,argv,"-walletstorageaudit") || + !strWalletStorageAuditJson.empty()) { - int nRet = CmdWalletStorageAudit(); + int nRet = CmdWalletStorageAudit(strWalletStorageAuditJson); DBFlush(true); return nRet; } diff --git a/src/selftest.cpp b/src/selftest.cpp index b112238..73c2935 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1329,6 +1329,24 @@ static int RunWalletPortabilitySelfTest() FileContainsText(strOriginalStorageAudit, "malformed records: 0"), "the storage audit recognizes a plaintext phrase wallet") ? 0 : 1; + string strOriginalStorageAuditJson = tmp + "/original-storage-audit.json"; + vector vOriginalStorageAuditJsonArgs; + vOriginalStorageAuditJsonArgs.push_back("-datadir=" + strOriginal); + vOriginalStorageAuditJsonArgs.push_back("-nomanagedtor"); + vOriginalStorageAuditJsonArgs.push_back("-nogui"); + vOriginalStorageAuditJsonArgs.push_back("-walletstorageauditjson=" + + strOriginalStorageAuditJson); + int nOriginalStorageAuditJsonRet = + RunBitflashChild(strExe, vOriginalStorageAuditJsonArgs); + nFail += Check(nOriginalStorageAuditJsonRet == 0 && + FileContainsText(strOriginalStorageAuditJson, + "\"format\": \"bitflash-wallet-storage-audit-v1\"") && + FileContainsText(strOriginalStorageAuditJson, + "\"plain_hd_seed\": \"complete\"") && + FileContainsText(strOriginalStorageAuditJson, + "\"encrypted_hd_seed\": \"none\""), + "the storage audit can write plaintext-wallet JSON") ? 0 : 1; + string strPortableWallet = tmp + "/portable-wallet.dat"; string strBackupOut = tmp + "/backupwallet.txt"; vector vBackupArgs; @@ -1400,6 +1418,26 @@ static int RunWalletPortabilitySelfTest() FileContainsText(strEncryptedStorageAudit, "wallet minimum version: present") && FileContainsText(strEncryptedStorageAudit, "malformed records: 0"), "the storage audit recognizes an encrypted wallet") ? 0 : 1; + + string strEncryptedStorageAuditJson = tmp + "/encrypted-storage-audit.json"; + vector vEncryptedStorageAuditJsonArgs; + vEncryptedStorageAuditJsonArgs.push_back("-datadir=" + strOriginal); + vEncryptedStorageAuditJsonArgs.push_back("-nomanagedtor"); + vEncryptedStorageAuditJsonArgs.push_back("-nogui"); + vEncryptedStorageAuditJsonArgs.push_back("-walletstorageauditjson=" + + strEncryptedStorageAuditJson); + int nEncryptedStorageAuditJsonRet = + RunBitflashChild(strExe, vEncryptedStorageAuditJsonArgs); + nFail += Check(nEncryptedStorageAuditJsonRet == 0 && + FileContainsText(strEncryptedStorageAuditJson, + "\"plain_private_keys\": 0") && + FileContainsText(strEncryptedStorageAuditJson, + "\"plain_hd_seed\": \"none\"") && + FileContainsText(strEncryptedStorageAuditJson, + "\"encrypted_hd_seed\": \"complete\"") && + FileContainsText(strEncryptedStorageAuditJson, + "\"wallet_minimum_version\": \"present\""), + "the storage audit can write encrypted-wallet JSON") ? 0 : 1; } catch (const std::exception& e) { diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index 896d593..6f8258d 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -605,7 +605,60 @@ static bool ReadWalletStorageCounts(WalletStorageAuditCounts& counts, return true; } -int CmdWalletStorageAudit() +static std::string WalletStorageAuditJson(const WalletStorageAuditCounts& counts) +{ + std::string strJson; + strJson += "{\n"; + strJson += " \"format\": \"bitflash-wallet-storage-audit-v1\",\n"; + strJson += strprintf(" \"records_total\": %u,\n", counts.nTotal); + strJson += strprintf(" \"malformed_records\": %u,\n", counts.nMalformed); + strJson += strprintf(" \"unknown_records\": %u,\n", counts.nUnknown); + strJson += strprintf(" \"plain_private_keys\": %u,\n", counts.nPlainKeys); + strJson += strprintf(" \"encrypted_private_keys\": %u,\n", counts.nEncryptedKeys); + strJson += strprintf(" \"encryption_master_keys\": %u,\n", counts.nMasterKeys); + strJson += strprintf(" \"default_public_key\": \"%s\",\n", + counts.nDefaultKey ? "present" : "none"); + strJson += strprintf(" \"plain_hd_seed\": \"%s\",\n", + HDSeedStorageState(counts.nPlainHDMaster, + counts.nPlainHDChainCode).c_str()); + strJson += strprintf(" \"encrypted_hd_seed\": \"%s\",\n", + HDSeedStorageState(counts.nCryptedHDMaster, + counts.nCryptedHDChainCode).c_str()); + strJson += strprintf(" \"wallet_minimum_version\": \"%s\",\n", + counts.nWalletMinVersion ? "present" : "none"); + strJson += strprintf(" \"keypool_entries\": %u,\n", counts.nPool); + strJson += strprintf(" \"wallet_transactions\": %u,\n", counts.nTransactions); + strJson += strprintf(" \"address_book_labels\": %u,\n", counts.nNames); + strJson += strprintf(" \"settings\": %u,\n", counts.nSettings); + strJson += strprintf(" \"hd_metadata_records\": %u,\n", + counts.nHDNext + counts.nHDSchema + counts.nHDCoinType + + counts.nHDReceiveNext + counts.nHDChangeNext); + strJson += strprintf(" \"database_version_records\": %u\n", counts.nVersion); + strJson += "}\n"; + return strJson; +} + +static bool WriteAuditTextFile(const std::string& strPath, + const std::string& strText, + std::string& strError) +{ + FILE* pf = fopen(strPath.c_str(), "wb"); + if (!pf) + { + strError = "cannot open output file"; + return false; + } + size_t nWritten = fwrite(strText.data(), 1, strText.size(), pf); + bool fCloseOk = fclose(pf) == 0; + if (nWritten != strText.size() || !fCloseOk) + { + strError = "could not write the complete output file"; + return false; + } + return true; +} + +int CmdWalletStorageAudit(const std::string& strJsonOut) { AttachTerminal(); @@ -617,6 +670,22 @@ int CmdWalletStorageAudit() return 1; } + if (!strJsonOut.empty()) + { + std::string strWriteError; + if (!WriteAuditTextFile(strJsonOut, + WalletStorageAuditJson(counts), + strWriteError)) + { + fprintf(stderr, "Cannot write wallet storage audit JSON: %s\n", + strWriteError.c_str()); + return 1; + } + printf("Wallet storage audit written to %s\n", strJsonOut.c_str()); + fflush(stdout); + return counts.nMalformed > 0 ? 2 : 0; + } + printf("Wallet storage audit\n"); printf(" records total: %u\n", counts.nTotal); printf(" malformed records: %u\n", counts.nMalformed); diff --git a/src/walletcmd.h b/src/walletcmd.h index 4a703db..c6342a6 100644 --- a/src/walletcmd.h +++ b/src/walletcmd.h @@ -64,7 +64,7 @@ int CmdRecoveryAudit(); // Diagnostic: count wallet.dat record types without printing keys, // addresses, transaction ids, labels, or other wallet values. -int CmdWalletStorageAudit(); +int CmdWalletStorageAudit(const std::string& strJsonOut = ""); // Rewrite wallet.dat so private keys and the HD seed are encrypted with the // given passphrase. Returns 0 on success and exits without starting the node. -- cgit v1.2.3 From 540ed06974ff6b05364d4922895288744f076f6b Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Thu, 13 Aug 2026 18:11:30 -0300 Subject: wallet: add storage sanity check --- src/main_gui.cpp | 9 ++++ src/selftest.cpp | 27 +++++++++++ src/walletcmd.cpp | 137 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/walletcmd.h | 4 ++ 4 files changed, 177 insertions(+) (limited to 'src/selftest.cpp') diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 395cc0f..8763211 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -176,6 +176,8 @@ static void PrintUsage() printf(" printing wallet values, then exit)\n"); printf(" /walletstorageauditjson=FILE\n"); printf(" (write the same audit as deterministic JSON)\n"); + printf(" /walletstoragecheck (fail closed on inconsistent or unsafe\n"); + printf(" wallet.dat storage state)\n"); printf(" /rescan (walk the chain for coins this wallet owns\n"); printf(" but never recorded, then exit)\n"); printf("\n"); @@ -719,6 +721,13 @@ int main(int argc, char* argv[]) return nRet; } + if (arg(argc,argv,"/walletstoragecheck") || arg(argc,argv,"-walletstoragecheck")) + { + int nRet = CmdWalletStorageCheck(); + 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 73c2935..5e5bab6 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1329,6 +1329,19 @@ static int RunWalletPortabilitySelfTest() FileContainsText(strOriginalStorageAudit, "malformed records: 0"), "the storage audit recognizes a plaintext phrase wallet") ? 0 : 1; + string strOriginalStorageCheck = tmp + "/original-storage-check.txt"; + vector vOriginalStorageCheckArgs; + vOriginalStorageCheckArgs.push_back("-datadir=" + strOriginal); + vOriginalStorageCheckArgs.push_back("-nomanagedtor"); + vOriginalStorageCheckArgs.push_back("-nogui"); + vOriginalStorageCheckArgs.push_back("-walletstoragecheck"); + int nOriginalStorageCheckRet = + RunBitflashChild(strExe, vOriginalStorageCheckArgs, NULL, &strOriginalStorageCheck); + nFail += Check(nOriginalStorageCheckRet == 0 && + FileContainsText(strOriginalStorageCheck, "storage sanity: ok") && + FileContainsText(strOriginalStorageCheck, "plain HD seed: complete"), + "the storage sanity check accepts a plaintext phrase wallet") ? 0 : 1; + string strOriginalStorageAuditJson = tmp + "/original-storage-audit.json"; vector vOriginalStorageAuditJsonArgs; vOriginalStorageAuditJsonArgs.push_back("-datadir=" + strOriginal); @@ -1419,6 +1432,20 @@ static int RunWalletPortabilitySelfTest() FileContainsText(strEncryptedStorageAudit, "malformed records: 0"), "the storage audit recognizes an encrypted wallet") ? 0 : 1; + string strEncryptedStorageCheck = tmp + "/encrypted-storage-check.txt"; + vector vEncryptedStorageCheckArgs; + vEncryptedStorageCheckArgs.push_back("-datadir=" + strOriginal); + vEncryptedStorageCheckArgs.push_back("-nomanagedtor"); + vEncryptedStorageCheckArgs.push_back("-nogui"); + vEncryptedStorageCheckArgs.push_back("-walletstoragecheck"); + int nEncryptedStorageCheckRet = + RunBitflashChild(strExe, vEncryptedStorageCheckArgs, NULL, &strEncryptedStorageCheck); + nFail += Check(nEncryptedStorageCheckRet == 0 && + FileContainsText(strEncryptedStorageCheck, "storage sanity: ok") && + FileContainsText(strEncryptedStorageCheck, "plain private keys: 0") && + FileContainsText(strEncryptedStorageCheck, "encrypted HD seed: complete"), + "the storage sanity check accepts an encrypted wallet") ? 0 : 1; + string strEncryptedStorageAuditJson = tmp + "/encrypted-storage-audit.json"; vector vEncryptedStorageAuditJsonArgs; vEncryptedStorageAuditJsonArgs.push_back("-datadir=" + strOriginal); diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index 6f8258d..265febe 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -13,6 +13,10 @@ #include "bip32.h" #include "walletcmd.h" +#ifndef _WIN32 +#include +#endif + #undef printf // How far ahead to look for used addresses when restoring. @@ -658,6 +662,139 @@ static bool WriteAuditTextFile(const std::string& strPath, return true; } +static bool DirectoryHasFileWithPrefixLocal(const std::string& strDir, + const std::string& strPrefix) +{ +#ifdef _WIN32 + std::string pattern = strDir + "/*"; + WIN32_FIND_DATAA findData; + HANDLE hFind = FindFirstFileA(pattern.c_str(), &findData); + if (hFind == INVALID_HANDLE_VALUE) + return false; + bool fFound = false; + do + { + if ((findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0) + { + std::string name = findData.cFileName; + if (name.compare(0, strPrefix.size(), strPrefix) == 0) + { + fFound = true; + break; + } + } + } + while (FindNextFileA(hFind, &findData)); + FindClose(hFind); + return fFound; +#else + DIR* dir = opendir(strDir.c_str()); + if (!dir) + return false; + bool fFound = false; + struct dirent* ent; + while ((ent = readdir(dir)) != NULL) + { + std::string name = ent->d_name; + if (name.compare(0, strPrefix.size(), strPrefix) == 0) + { + fFound = true; + break; + } + } + closedir(dir); + return fFound; +#endif +} + +static void AddWalletStorageFailure(std::vector& vFailures, + const std::string& strFailure) +{ + vFailures.push_back(strFailure); +} + +static void BuildWalletStorageSanityFailures(const WalletStorageAuditCounts& counts, + std::vector& vFailures) +{ + bool fPlainHD = counts.nPlainHDMaster > 0 || counts.nPlainHDChainCode > 0; + bool fCryptedHD = counts.nCryptedHDMaster > 0 || counts.nCryptedHDChainCode > 0; + bool fEncryptedRecords = counts.nEncryptedKeys > 0 || + counts.nMasterKeys > 0 || + fCryptedHD; + + if (counts.nMalformed > 0) + AddWalletStorageFailure(vFailures, "wallet.dat contains malformed records"); + if (counts.nUnknown > 0) + AddWalletStorageFailure(vFailures, "wallet.dat contains unknown records"); + if ((counts.nPlainHDMaster > 0) != (counts.nPlainHDChainCode > 0)) + AddWalletStorageFailure(vFailures, "plain HD seed is incomplete"); + if ((counts.nCryptedHDMaster > 0) != (counts.nCryptedHDChainCode > 0)) + AddWalletStorageFailure(vFailures, "encrypted HD seed is incomplete"); + if (fPlainHD && fCryptedHD) + AddWalletStorageFailure(vFailures, "wallet.dat contains both plain and encrypted HD seed records"); + if (counts.nDefaultKey > 1) + AddWalletStorageFailure(vFailures, "wallet.dat contains more than one default public key record"); + if (counts.nWalletMinVersion > 1) + AddWalletStorageFailure(vFailures, "wallet.dat contains more than one minimum-version record"); + + if (fEncryptedRecords) + { + if (counts.nPlainKeys > 0) + AddWalletStorageFailure(vFailures, "encrypted wallet still contains plain private key records"); + if (fPlainHD) + AddWalletStorageFailure(vFailures, "encrypted wallet still contains plain HD seed records"); + if (counts.nMasterKeys == 0) + AddWalletStorageFailure(vFailures, "encrypted wallet has no encryption master key record"); + if (counts.nWalletMinVersion == 0) + AddWalletStorageFailure(vFailures, "encrypted wallet has no minimum-version record"); + if (DirectoryHasFileWithPrefixLocal(GetAppDir() + "/database", "log.")) + AddWalletStorageFailure(vFailures, "Berkeley DB log files are still present for an encrypted wallet"); + } +} + +int CmdWalletStorageCheck() +{ + AttachTerminal(); + + WalletStorageAuditCounts counts; + std::string strError; + if (!ReadWalletStorageCounts(counts, strError)) + { + fprintf(stderr, "Cannot check wallet storage: %s\n", strError.c_str()); + return 1; + } + + std::vector vFailures; + BuildWalletStorageSanityFailures(counts, vFailures); + + printf("Wallet storage sanity check\n"); + printf(" records total: %u\n", counts.nTotal); + printf(" encrypted records: %s\n", + (counts.nEncryptedKeys || counts.nMasterKeys || + counts.nCryptedHDMaster || counts.nCryptedHDChainCode) ? "yes" : "no"); + printf(" plain private keys: %u\n", counts.nPlainKeys); + printf(" encrypted private keys: %u\n", counts.nEncryptedKeys); + printf(" plain HD seed: %s\n", + HDSeedStorageState(counts.nPlainHDMaster, + counts.nPlainHDChainCode).c_str()); + printf(" encrypted HD seed: %s\n", + HDSeedStorageState(counts.nCryptedHDMaster, + counts.nCryptedHDChainCode).c_str()); + if (vFailures.empty()) + { + printf(" storage sanity: ok\n"); + fflush(stdout); + return 0; + } + + printf(" storage sanity: failed\n"); + for (std::vector::const_iterator it = vFailures.begin(); + it != vFailures.end(); ++it) + printf(" failure: %s\n", it->c_str()); + fflush(stdout); + return 2; +} + int CmdWalletStorageAudit(const std::string& strJsonOut) { AttachTerminal(); diff --git a/src/walletcmd.h b/src/walletcmd.h index c6342a6..6077669 100644 --- a/src/walletcmd.h +++ b/src/walletcmd.h @@ -66,6 +66,10 @@ int CmdRecoveryAudit(); // addresses, transaction ids, labels, or other wallet values. int CmdWalletStorageAudit(const std::string& strJsonOut = ""); +// Diagnostic: fail closed when wallet.dat contains an internally inconsistent +// or migration-unsafe mix of storage records. +int CmdWalletStorageCheck(); + // Rewrite wallet.dat so private keys and the HD seed are encrypted with the // given passphrase. Returns 0 on success and exits without starting the node. int CmdEncryptWallet(const std::string& strPassphrase); -- cgit v1.2.3 From daf65cd1957eb529be31baf0614b741ff11e7771 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 14 Aug 2026 09:59:29 -0300 Subject: tests: prove encrypted rewrite keeps public wallet records --- src/selftest.cpp | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) (limited to 'src/selftest.cpp') diff --git a/src/selftest.cpp b/src/selftest.cpp index 5e5bab6..638aa66 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1134,6 +1134,13 @@ static int RunWalletEncryptSelfTest() TopUpKeyPool(); vector vchDefaultPubKey = keyUser.GetPubKey(); + CKey publicLabelKey; + publicLabelKey.MakeNewKey(); + string strPublicLabelAddress = PubKeyToAddress(publicLabelKey.GetPubKey()); + nFail += Check(CWalletDB().WriteName(strPublicLabelAddress, "rewrite-public-name") && + CWalletDB().WriteSetting("rewrite-public-setting", (int64)424242), + "public wallet records can be written before encryption") ? 0 : 1; + CPrivKey vchDefaultPrivKey; nFail += Check(GetWalletPrivKey(vchDefaultPubKey, vchDefaultPrivKey, strError), "the default private key is readable before encryption") ? 0 : 1; @@ -1161,6 +1168,7 @@ static int RunWalletEncryptSelfTest() #else string strExe = cwd + "/bitflash-node"; #endif + string strStorageAudit = tmp + "/encrypted-storage-audit.txt"; string strWrongDump = tmp + "/wrong-pass-dump.txt"; string strRightDump = tmp + "/right-pass-dump.txt"; string strLiteralDump = tmp + "/literal-pass-dump.txt"; @@ -1173,6 +1181,18 @@ static int RunWalletEncryptSelfTest() WriteTextFile(strRightPassFile, "btf-test-passphrase\n"), "passphrase files can be written for restarted commands") ? 0 : 1; + vector vStorageAuditArgs; + vStorageAuditArgs.push_back("-datadir=" + tmp); + vStorageAuditArgs.push_back("-walletstorageaudit"); + vStorageAuditArgs.push_back("-nogui"); + int nStorageAuditRet = RunBitflashChild(strExe, vStorageAuditArgs, NULL, &strStorageAudit); + nFail += Check(nStorageAuditRet == 0 && + FileContainsText(strStorageAudit, "address book labels: 3"), + "the encrypted rewrite keeps address-book records") ? 0 : 1; + nFail += Check(nStorageAuditRet == 0 && + FileContainsText(strStorageAudit, "settings: 1"), + "the encrypted rewrite keeps wallet setting records") ? 0 : 1; + vector vWrongArgs; vWrongArgs.push_back("-datadir=" + tmp); vWrongArgs.push_back("-walletpassphrase=@" + strWrongPassFile); -- cgit v1.2.3 From dd9db5ba5407b9c30b4f41fc1153db39f0770154 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 14 Aug 2026 11:08:34 -0300 Subject: wallet: install backups via temporary file --- docs/storage.md | 3 ++- src/db.cpp | 40 ++++++++++++++++++++++++++++++++++------ src/selftest.cpp | 7 +++++++ 3 files changed, 43 insertions(+), 7 deletions(-) (limited to 'src/selftest.cpp') diff --git a/docs/storage.md b/docs/storage.md index e4a66d2..c1cda45 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -77,7 +77,8 @@ A future wallet backend must keep these properties: 3. A locked encrypted wallet may expose public keys and balances, but not private keys or decrypted HD seed material. 4. `-backupwallet` must produce a wallet file that opens in a different datadir - without copying `database/`. + without copying `database/`, and must not leave a half-written final backup + path if the copy or install step fails. 5. `-recoveryaudit` must distinguish: - no recovery phrase installed; - recovery phrase present; diff --git a/src/db.cpp b/src/db.cpp index b90219e..0ff58e7 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -1030,6 +1030,13 @@ bool BackupWallet(const string& strDest) if (!FileExists(strSrc.c_str())) return error("BackupWallet() : %s does not exist\n", strSrc.c_str()); + string strTemp = strprintf("%s.tmp.%lld", strDest.c_str(), GetTime()); + for (int i = 1; FileExists(strTemp.c_str()); i++) + strTemp = strprintf("%s.tmp.%lld.%d", strDest.c_str(), GetTime(), i); + string strOld = strprintf("%s.old.%lld", strDest.c_str(), GetTime()); + for (int i = 1; FileExists(strOld.c_str()); i++) + strOld = strprintf("%s.old.%lld.%d", strDest.c_str(), GetTime(), i); + CRITICAL_BLOCK(cs_db) { // Committing first, so the copy is not missing the newest records. @@ -1044,11 +1051,11 @@ bool BackupWallet(const string& strDest) FILE* pfIn = fopen(strSrc.c_str(), "rb"); if (!pfIn) return error("BackupWallet() : cannot read %s\n", strSrc.c_str()); - FILE* pfOut = fopen(strDest.c_str(), "wb"); + FILE* pfOut = fopen(strTemp.c_str(), "wb"); if (!pfOut) { fclose(pfIn); - return error("BackupWallet() : cannot write %s\n", strDest.c_str()); + return error("BackupWallet() : cannot write %s\n", strTemp.c_str()); } char buf[65536]; @@ -1065,18 +1072,39 @@ bool BackupWallet(const string& strDest) if (!fOk) { - remove(strDest.c_str()); // half a wallet is worse than none - return error("BackupWallet() : copy to %s failed\n", strDest.c_str()); + remove(strTemp.c_str()); // half a wallet is worse than none + return error("BackupWallet() : copy to %s failed\n", strTemp.c_str()); } // Without this the copy is welded to this node's database/ dir. - int ret = dbenv.lsn_reset(strDest.c_str(), 0); + int ret = dbenv.lsn_reset(strTemp.c_str(), 0); if (ret != 0) printf("BackupWallet() : warning -- lsn_reset returned %d; the copy " "may only open beside this node's database/ directory\n", ret); + + bool fHadOldDest = FileExists(strDest.c_str()); + if (fHadOldDest && rename(strDest.c_str(), strOld.c_str()) != 0) + { + remove(strTemp.c_str()); + return error("BackupWallet() : could not move old %s aside\n", strDest.c_str()); + } + if (rename(strTemp.c_str(), strDest.c_str()) != 0) + { + remove(strTemp.c_str()); + if (fHadOldDest) + rename(strOld.c_str(), strDest.c_str()); + return error("BackupWallet() : could not install %s\n", strDest.c_str()); + } + if (fHadOldDest) + remove(strOld.c_str()); } catch (const std::exception& e) - { return error("BackupWallet() : %s\n", e.what()); } + { + remove(strTemp.c_str()); + if (FileExists(strOld.c_str()) && !FileExists(strDest.c_str())) + rename(strOld.c_str(), strDest.c_str()); + return error("BackupWallet() : %s\n", e.what()); + } } // Recorded here rather than by whoever called, so that a backup taken with diff --git a/src/selftest.cpp b/src/selftest.cpp index 638aa66..81c4439 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1391,6 +1391,13 @@ static int RunWalletPortabilitySelfTest() nFail += Check(nBackupRet == 0 && FileExists(strPortableWallet.c_str()), "/backupwallet writes a portable wallet.dat") ? 0 : 1; + string strBackupOut2 = tmp + "/backupwallet-overwrite.txt"; + int nBackupRet2 = RunBitflashChild(strExe, vBackupArgs, NULL, &strBackupOut2); + nFail += Check(nBackupRet2 == 0 && FileExists(strPortableWallet.c_str()) && + !DirectoryHasFileWithPrefix(tmp, "portable-wallet.dat.tmp.") && + !DirectoryHasFileWithPrefix(tmp, "portable-wallet.dat.old."), + "/backupwallet replaces an existing backup without leaving temp files") ? 0 : 1; + nFail += Check(CopyFileLocal(strOriginal + "/wallet.dat", strRawCopy + "/wallet.dat"), "a raw wallet.dat can be copied without database logs") ? 0 : 1; -- cgit v1.2.3 From 83b9c4feddeac14bbc5edf63b44d3a2013384ecc Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 14 Aug 2026 11:51:14 -0300 Subject: tests: prove wallet storage sanity failures --- src/main_gui.cpp | 50 +++++++++------ src/selftest.cpp | 187 +++++++++++++++++++++++++++++++++++++++++++++++++++++- src/selftest.h | 1 + src/walletcmd.cpp | 5 ++ 4 files changed, 222 insertions(+), 21 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 8763211..feda428 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -113,8 +113,9 @@ static void PrintUsage() printf(" /debug\n"); printf(" /gen\n"); printf(" /nogui or /daemon\n"); - printf(" /selftest=wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt,\n"); - printf(" wallet-portability, net-message, consensus-limits, pool-stratum,\n"); + printf(" /selftest=wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity,\n"); + printf(" wallet-crypto, wallet-encrypt, wallet-portability, net-message,\n"); + printf(" consensus-limits, pool-stratum,\n"); printf(" parse-money, socks5-proxy, or managed-tor\n"); printf("\n"); printf("Mining mode:\n"); @@ -495,10 +496,37 @@ int main(int argc, char* argv[]) ParseStartupArguments(argc, argv); + // Hidden self-test helper. It must run before LoadWallet(): several + // storage-sanity scenarios deliberately make wallet.dat unsafe to load. + string strSelfTestMutateWallet = + argval2(argc, argv, "/selftestmutatewallet", "-selftestmutatewallet"); + if (!strSelfTestMutateWallet.empty()) + return RunSelfTestMutateWallet(strSelfTestMutateWallet); + string strSelfTest = argval2(argc, argv, "/selftest", "-selftest"); if (!strSelfTest.empty()) return RunSelfTest(strSelfTest); + // Storage diagnostics must run before LoadWallet(). Their job is to + // explain wallet.dat states that the normal wallet loader may refuse. + string strWalletStorageAuditJson = + argval2(argc, argv, "/walletstorageauditjson", "-walletstorageauditjson"); + if (arg(argc,argv,"/walletstorageaudit") || + arg(argc,argv,"-walletstorageaudit") || + !strWalletStorageAuditJson.empty()) + { + int nRet = CmdWalletStorageAudit(strWalletStorageAuditJson); + DBFlush(true); + return nRet; + } + + if (arg(argc,argv,"/walletstoragecheck") || arg(argc,argv,"-walletstoragecheck")) + { + int nRet = CmdWalletStorageCheck(); + DBFlush(true); + return nRet; + } + // Berkeley DB reports failure by throwing, and CDB's constructor lets it // through. Nothing on this path caught anything, so an unreadable // blkindex.dat or wallet.dat -- one written by another platform's Berkeley @@ -710,24 +738,6 @@ int main(int argc, char* argv[]) return nRet; } - string strWalletStorageAuditJson = - argval2(argc, argv, "/walletstorageauditjson", "-walletstorageauditjson"); - if (arg(argc,argv,"/walletstorageaudit") || - arg(argc,argv,"-walletstorageaudit") || - !strWalletStorageAuditJson.empty()) - { - int nRet = CmdWalletStorageAudit(strWalletStorageAuditJson); - DBFlush(true); - return nRet; - } - - if (arg(argc,argv,"/walletstoragecheck") || arg(argc,argv,"-walletstoragecheck")) - { - int nRet = CmdWalletStorageCheck(); - 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 81c4439..81aa480 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -736,6 +736,189 @@ static int RunWalletFormatSelfTest() return nFail == 0 ? 0 : 1; } +static bool FileContainsText(const string& strPath, const string& strNeedle); +static bool MakeDirLocal(const string& strPath); +static int RunBitflashChild(const string& strExe, + const vector& vArgs, + const string* pStdinFile, + const string* pOutputFile); + +class CWalletStorageSanityRawDB : public CWalletDB +{ +public: + CWalletStorageSanityRawDB(const char* pszMode="r+") + : CWalletDB(pszMode) { } + + bool WriteUnknownRecord() + { + return Write(string("storage-sanity-unknown"), 1); + } + + bool WriteMalformedKeyRecord() + { + if (!pdb) + return false; + unsigned char chKey = 0xff; + unsigned char chValue = 0x01; + Dbt datKey(&chKey, 1); + Dbt datValue(&chValue, 1); + return pdb->put(GetTxn(), &datKey, &datValue, 0) == 0; + } + + bool MakePlainHDSeedIncomplete() + { + return Erase(string("hdchaincode")); + } + + bool AddEncryptedMarkerToPlainWallet() + { + CWalletMasterKey kMasterKey; + kMasterKey.vchCryptedKey.resize(48); + for (size_t i = 0; i < kMasterKey.vchCryptedKey.size(); i++) + kMasterKey.vchCryptedKey[i] = (unsigned char)(0x80 + (i & 0x3f)); + for (size_t i = 0; i < kMasterKey.vchSalt.size(); i++) + kMasterKey.vchSalt[i] = (unsigned char)(0x40 + i); + return WriteWalletMinVersion(WALLET_FORMAT_ENCRYPTED) && + WriteMasterKey(1, kMasterKey); + } +}; + +int RunSelfTestMutateWallet(const std::string& name) +{ + bool fOk = false; + try + { + CWalletStorageSanityRawDB walletdb; + if (name == "unknown-record") + fOk = walletdb.WriteUnknownRecord(); + else if (name == "malformed-key") + fOk = walletdb.WriteMalformedKeyRecord(); + else if (name == "incomplete-plain-hd") + fOk = walletdb.MakePlainHDSeedIncomplete(); + else if (name == "encrypted-marker-with-plain-keys") + fOk = walletdb.AddEncryptedMarkerToPlainWallet(); + else + { + fprintf(stderr, "Unknown wallet storage mutation '%s'\n", name.c_str()); + return 1; + } + } + catch (const std::exception& e) + { + fprintf(stderr, "Cannot mutate wallet storage: %s\n", e.what()); + return 1; + } + catch (...) + { + fprintf(stderr, "Cannot mutate wallet storage: unknown exception\n"); + return 1; + } + + DBFlush(true); + return fOk ? 0 : 1; +} + +static int RunWalletStorageSanitySelfTest() +{ + fflush(stdout); + printf("wallet-storage-sanity self-test\n"); + + std::string tmp; + std::string cwd; + if (!MakeTempDir(tmp)) + { + printf(" FAIL could not create a temporary data directory\n"); + return 1; + } + if (!GetCurrentDir(cwd)) + { + printf(" FAIL could not read current directory\n"); + RemoveTree(tmp); + return 1; + } + + int nFail = 0; + printf(" temp root: %s\n", tmp.c_str()); + + try + { +#ifdef _WIN32 + string strExe = cwd + "\\bitflash.exe"; +#else + string strExe = cwd + "/bitflash-node"; +#endif + + struct StorageScenario + { + const char* pszName; + const char* pszExpectedFailure; + }; + + StorageScenario scenarios[] = { + { "unknown-record", "wallet.dat contains unknown records" }, + { "malformed-key", "wallet.dat contains malformed records" }, + { "incomplete-plain-hd", "plain HD seed is incomplete" }, + { "encrypted-marker-with-plain-keys", + "encrypted wallet still contains plain private key records" }, + }; + + for (size_t i = 0; i < sizeof(scenarios)/sizeof(scenarios[0]); i++) + { + string strScenarioDir = tmp + "/" + scenarios[i].pszName; + nFail += Check(MakeDirLocal(strScenarioDir), + "scenario datadir can be created") ? 0 : 1; + + string strCreateOut = tmp + "/" + scenarios[i].pszName + "-create.txt"; + vector vCreateArgs; + vCreateArgs.push_back("-datadir=" + strScenarioDir); + vCreateArgs.push_back("-nomanagedtor"); + vCreateArgs.push_back("-nogui"); + vCreateArgs.push_back("-newphrase"); + int nCreateRet = RunBitflashChild(strExe, vCreateArgs, NULL, &strCreateOut); + nFail += Check(nCreateRet == 0 && + FileContainsText(strCreateOut, "Write these twelve words down"), + "scenario phrase wallet can be created") ? 0 : 1; + + string strMutateOut = tmp + "/" + scenarios[i].pszName + "-mutate.txt"; + vector vMutateArgs; + vMutateArgs.push_back("-datadir=" + strScenarioDir); + vMutateArgs.push_back("-nomanagedtor"); + vMutateArgs.push_back("-nogui"); + vMutateArgs.push_back("-selftestmutatewallet=" + string(scenarios[i].pszName)); + int nMutateRet = RunBitflashChild(strExe, vMutateArgs, NULL, &strMutateOut); + nFail += Check(nMutateRet == 0, scenarios[i].pszName) ? 0 : 1; + + string strCheckOut = tmp + "/" + scenarios[i].pszName + "-check.txt"; + vector vCheckArgs; + vCheckArgs.push_back("-datadir=" + strScenarioDir); + vCheckArgs.push_back("-nomanagedtor"); + vCheckArgs.push_back("-nogui"); + vCheckArgs.push_back("-walletstoragecheck"); + int nCheckRet = RunBitflashChild(strExe, vCheckArgs, NULL, &strCheckOut); + nFail += Check(nCheckRet == 2 && + FileContainsText(strCheckOut, "storage sanity: failed") && + FileContainsText(strCheckOut, scenarios[i].pszExpectedFailure), + scenarios[i].pszExpectedFailure) ? 0 : 1; + } + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + RemoveTree(tmp); + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + static void ClearWalletRuntimeForTest() { CRITICAL_BLOCK(cs_mapKeys) @@ -1991,6 +2174,8 @@ int RunSelfTest(const std::string& name) return RunWalletHDSelfTest(); if (name == "wallet-format") return RunWalletFormatSelfTest(); + if (name == "wallet-storage-sanity") + return RunWalletStorageSanitySelfTest(); if (name == "wallet-crypto") return RunWalletCryptoSelfTest(); if (name == "wallet-encrypt") @@ -2011,6 +2196,6 @@ int RunSelfTest(const std::string& name) return RunManagedTorSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, wallet-crypto, wallet-encrypt, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); return 1; } diff --git a/src/selftest.h b/src/selftest.h index d3699c6..0ffe232 100644 --- a/src/selftest.h +++ b/src/selftest.h @@ -7,5 +7,6 @@ #include int RunSelfTest(const std::string& name); +int RunSelfTestMutateWallet(const std::string& name); #endif diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index 71f1ec3..6fb09a3 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -573,6 +573,11 @@ static bool ReadWalletStorageCounts(WalletStorageAuditCounts& counts, CDataStream ssKey = ssKeyIn; std::string strType; ssKey >> strType; + if (ssKey.fail()) + { + counts.nMalformed++; + return true; + } CountWalletStorageType(strType, counts); } catch (...) -- cgit v1.2.3 From 23d5c397c35a5c4dad6e3fe170aefdfa2fd00580 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 14 Aug 2026 12:28:55 -0300 Subject: tests: guard destructive wallet mutation helper --- src/selftest.cpp | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) (limited to 'src/selftest.cpp') diff --git a/src/selftest.cpp b/src/selftest.cpp index 81aa480..54249b1 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -783,8 +783,40 @@ public: } }; +static const char* SELFTEST_MUTATE_WALLET_MARKER = + ".bitflash-selftest-wallet-mutate-ok"; +static const char* SELFTEST_MUTATE_WALLET_MARKER_TEXT = + "bitflash destructive wallet storage self-test\n"; + +static string SelfTestMutationMarkerPath() +{ + return GetAppDir() + "/" + SELFTEST_MUTATE_WALLET_MARKER; +} + +static bool WriteSelfTestMutationMarker(const string& strDir) +{ + string strPath = strDir + "/" + SELFTEST_MUTATE_WALLET_MARKER; + FILE* pf = fopen(strPath.c_str(), "wb"); + if (!pf) + return false; + bool fOk = fwrite(SELFTEST_MUTATE_WALLET_MARKER_TEXT, 1, + strlen(SELFTEST_MUTATE_WALLET_MARKER_TEXT), pf) == + strlen(SELFTEST_MUTATE_WALLET_MARKER_TEXT); + if (fclose(pf) != 0) + fOk = false; + return fOk; +} + int RunSelfTestMutateWallet(const std::string& name) { + if (!FileContainsText(SelfTestMutationMarkerPath(), + SELFTEST_MUTATE_WALLET_MARKER_TEXT)) + { + fprintf(stderr, "Refusing destructive wallet storage mutation outside " + "a marked self-test datadir.\n"); + return 1; + } + bool fOk = false; try { @@ -879,6 +911,23 @@ static int RunWalletStorageSanitySelfTest() FileContainsText(strCreateOut, "Write these twelve words down"), "scenario phrase wallet can be created") ? 0 : 1; + if (i == 0) + { + string strBlockedOut = tmp + "/" + scenarios[i].pszName + "-blocked-mutate.txt"; + vector vBlockedArgs; + vBlockedArgs.push_back("-datadir=" + strScenarioDir); + vBlockedArgs.push_back("-nomanagedtor"); + vBlockedArgs.push_back("-nogui"); + vBlockedArgs.push_back("-selftestmutatewallet=" + string(scenarios[i].pszName)); + int nBlockedRet = RunBitflashChild(strExe, vBlockedArgs, NULL, &strBlockedOut); + nFail += Check(nBlockedRet != 0 && + FileContainsText(strBlockedOut, "Refusing destructive wallet storage mutation"), + "wallet mutation helper is blocked without marker") ? 0 : 1; + } + + nFail += Check(WriteSelfTestMutationMarker(strScenarioDir), + "scenario mutation marker can be written") ? 0 : 1; + string strMutateOut = tmp + "/" + scenarios[i].pszName + "-mutate.txt"; vector vMutateArgs; vMutateArgs.push_back("-datadir=" + strScenarioDir); -- cgit v1.2.3 From 82c3e10da01260650ddd2944e3f895ccf2c07ffc Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 14 Aug 2026 12:59:03 -0300 Subject: db: recreate Berkeley DB environment after shutdown --- src/db.cpp | 93 +++++++++++++++++++++++++++++++++++++------------------- src/db.h | 4 +-- src/main_gui.cpp | 3 +- src/selftest.cpp | 66 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 130 insertions(+), 36 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/db.cpp b/src/db.cpp index 0ff58e7..8b82fe5 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -52,9 +52,34 @@ static bool fDbEnvInit = false; // Why LoadWallet() refused, in words meant for the person who has to act on it. // Empty unless the reason is one we can name. string strWalletLoadError; -DbEnv dbenv(0u); +static DbEnv* pdbenv = NULL; +static FILE* pdbErrFile = NULL; static map mapFileUseCount; +DbEnv& GetDbEnv() +{ + if (!pdbenv) + pdbenv = new DbEnv(0u); + return *pdbenv; +} + +static void CloseDbEnv() +{ + if (pdbenv) + { + try { pdbenv->close(0); } + catch (...) { } + delete pdbenv; + pdbenv = NULL; + } + if (pdbErrFile) + { + fclose(pdbErrFile); + pdbErrFile = NULL; + } + fDbEnvInit = false; +} + class CDBInit { public: @@ -64,11 +89,7 @@ public: ~CDBInit() { if (fDbEnvInit) - { - try { dbenv.close(0); } - catch (...) { } - fDbEnvInit = false; - } + CloseDbEnv(); } } instance_of_cdbinit; @@ -104,24 +125,30 @@ CDB::CDB(const char* pszFile, const char* pszMode, bool fTxn) : pdb(NULL) printf("dbenv.open strAppDir=%s\n", strAppDir.c_str()); - dbenv.set_lg_dir(strLogDir.c_str()); - dbenv.set_lg_max(10000000); - dbenv.set_lk_max_locks(10000); - dbenv.set_lk_max_objects(10000); - dbenv.set_errfile(fopen("db.log", "a")); /// debug - ///dbenv.log_set_config(DB_LOG_AUTO_REMOVE, 1); /// causes corruption - ret = dbenv.open(strAppDir.c_str(), - DB_CREATE | - DB_INIT_LOCK | - DB_INIT_LOG | - DB_INIT_MPOOL | - DB_INIT_TXN | - DB_THREAD | - DB_PRIVATE | - DB_RECOVER, - 0); + DbEnv& env = GetDbEnv(); + env.set_lg_dir(strLogDir.c_str()); + env.set_lg_max(10000000); + env.set_lk_max_locks(10000); + env.set_lk_max_objects(10000); + pdbErrFile = fopen("db.log", "a"); /// debug + if (pdbErrFile) + env.set_errfile(pdbErrFile); + ///env.log_set_config(DB_LOG_AUTO_REMOVE, 1); /// causes corruption + ret = env.open(strAppDir.c_str(), + DB_CREATE | + DB_INIT_LOCK | + DB_INIT_LOG | + DB_INIT_MPOOL | + DB_INIT_TXN | + DB_THREAD | + DB_PRIVATE | + DB_RECOVER, + 0); if (ret > 0) + { + CloseDbEnv(); throw runtime_error(strprintf("CDB() : error %d opening database environment\n", ret)); + } fDbEnvInit = true; // A Berkeley DB file records, in every page header, a log sequence @@ -151,7 +178,7 @@ CDB::CDB(const char* pszFile, const char* pszMode, bool fTxn) : pdb(NULL) continue; try { - if (dbenv.lsn_reset(pszName, 0) == 0) + if (env.lsn_reset(pszName, 0) == 0) printf("CDB() : adopted %s from another environment " "(log sequence numbers reset)\n", pszName); } @@ -164,7 +191,7 @@ CDB::CDB(const char* pszFile, const char* pszMode, bool fTxn) : pdb(NULL) ++mapFileUseCount[strFile]; } - pdb = new Db(&dbenv, 0); + pdb = new Db(&GetDbEnv(), 0); ret = pdb->open(NULL, // Txn pointer pszFile, // Filename @@ -199,7 +226,8 @@ void CDB::Close() try { pdb->close(0); } catch (...) { } delete pdb; pdb = NULL; - try { dbenv.txn_checkpoint(0, 0, 0); } catch (...) { } + if (fDbEnvInit) + try { GetDbEnv().txn_checkpoint(0, 0, 0); } catch (...) { } CRITICAL_BLOCK(cs_db) --mapFileUseCount[strFile]; @@ -279,7 +307,8 @@ void DBFlush(bool fShutdown) // takes the process down before the wallet has been put down cleanly, // which is the difference between a portable wallet.dat and one welded // to this directory -- the lsn_reset just below is what frees it. - try { dbenv.txn_checkpoint(0, 0, 0); } + DbEnv& env = GetDbEnv(); + try { env.txn_checkpoint(0, 0, 0); } catch (const std::exception& e) { printf("DBFlush() : checkpoint failed: %s\n", e.what()); } @@ -290,7 +319,7 @@ void DBFlush(bool fShutdown) int nRefCount = (*mi).second; if (nRefCount == 0) { - try { dbenv.lsn_reset(strFile.c_str(), 0); } + try { env.lsn_reset(strFile.c_str(), 0); } catch (const std::exception& e) { printf("DBFlush() : lsn_reset(%s) failed: %s\n", strFile.c_str(), e.what()); } mapFileUseCount.erase(mi++); @@ -310,9 +339,8 @@ void DBFlush(bool fShutdown) // Found while making wallet encryption clean up after itself: // calling DBFlush(true) a second time crashed here every time. if (mapFileUseCount.empty()) - try { dbenv.log_archive(NULL, DB_ARCH_REMOVE); } catch (...) { } - try { dbenv.close(0); } catch (...) { } - fDbEnvInit = false; + try { env.log_archive(NULL, DB_ARCH_REMOVE); } catch (...) { } + CloseDbEnv(); } } } @@ -1042,7 +1070,8 @@ bool BackupWallet(const string& strDest) // Committing first, so the copy is not missing the newest records. // Both calls can throw, and a failed backup must not take the node // down with it. - try { dbenv.txn_checkpoint(0, 0, 0); } + DbEnv& env = GetDbEnv(); + try { env.txn_checkpoint(0, 0, 0); } catch (const std::exception& e) { return error("BackupWallet() : checkpoint failed: %s\n", e.what()); } @@ -1077,7 +1106,7 @@ bool BackupWallet(const string& strDest) } // Without this the copy is welded to this node's database/ dir. - int ret = dbenv.lsn_reset(strTemp.c_str(), 0); + int ret = env.lsn_reset(strTemp.c_str(), 0); if (ret != 0) printf("BackupWallet() : warning -- lsn_reset returned %d; the copy " "may only open beside this node's database/ directory\n", ret); diff --git a/src/db.h b/src/db.h index 2816957..53bda59 100644 --- a/src/db.h +++ b/src/db.h @@ -48,7 +48,7 @@ public: }; -extern DbEnv dbenv; +extern DbEnv& GetDbEnv(); extern void DBFlush(bool fShutdown); // Close the environment and delete its logs. Only the encryption command calls // this: the logs hold the plaintext records the old wallet wrote. @@ -237,7 +237,7 @@ public: if (!pdb) return false; DbTxn* ptxn = NULL; - int ret = dbenv.txn_begin(GetTxn(), &ptxn, 0); + int ret = GetDbEnv().txn_begin(GetTxn(), &ptxn, 0); if (!ptxn || ret != 0) return false; vTxn.push_back(ptxn); diff --git a/src/main_gui.cpp b/src/main_gui.cpp index feda428..705b4ef 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -114,7 +114,8 @@ static void PrintUsage() printf(" /gen\n"); printf(" /nogui or /daemon\n"); printf(" /selftest=wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity,\n"); - printf(" wallet-crypto, wallet-encrypt, wallet-portability, net-message,\n"); + printf(" db-env-reopen, wallet-crypto, wallet-encrypt, wallet-portability,\n"); + printf(" net-message,\n"); printf(" consensus-limits, pool-stratum,\n"); printf(" parse-money, socks5-proxy, or managed-tor\n"); printf("\n"); diff --git a/src/selftest.cpp b/src/selftest.cpp index 54249b1..31c168f 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -968,6 +968,68 @@ static int RunWalletStorageSanitySelfTest() return nFail == 0 ? 0 : 1; } +static int RunDbEnvReopenSelfTest() +{ + fflush(stdout); + printf("db-env-reopen self-test\n"); + + std::string tmp; + if (!MakeTempDir(tmp)) + { + printf(" FAIL could not create a temporary data directory\n"); + return 1; + } + + int nFail = 0; + std::string strOldDataDir = strSetDataDir; + strSetDataDir = tmp; + printf(" temp datadir: %s\n", tmp.c_str()); + + try + { + for (int i = 0; i < 5; i++) + { + int64 nValue = 1000 + i; + { + CWalletDB walletdb("cr+"); + nFail += Check(walletdb.WriteSetting("db-env-reopen-cycle", nValue), + "wallet setting can be written before shutdown") ? 0 : 1; + } + + DBFlush(true); + + int64 nRead = 0; + { + CWalletDB walletdb("r"); + nFail += Check(walletdb.ReadSetting("db-env-reopen-cycle", nRead), + "wallet setting can be read after env reopen") ? 0 : 1; + } + nFail += Check(nRead == nValue, + "reopened environment reads the last committed value") ? 0 : 1; + + DBFlush(true); + } + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + DBFlush(true); + strSetDataDir = strOldDataDir; + RemoveTree(tmp); + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + static void ClearWalletRuntimeForTest() { CRITICAL_BLOCK(cs_mapKeys) @@ -2225,6 +2287,8 @@ int RunSelfTest(const std::string& name) return RunWalletFormatSelfTest(); if (name == "wallet-storage-sanity") return RunWalletStorageSanitySelfTest(); + if (name == "db-env-reopen") + return RunDbEnvReopenSelfTest(); if (name == "wallet-crypto") return RunWalletCryptoSelfTest(); if (name == "wallet-encrypt") @@ -2245,6 +2309,6 @@ int RunSelfTest(const std::string& name) return RunManagedTorSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, wallet-crypto, wallet-encrypt, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, db-env-reopen, wallet-crypto, wallet-encrypt, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); return 1; } -- cgit v1.2.3 From c49b92a9a79a398b51200e69f7dde8a467b9c925 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 14 Aug 2026 15:03:21 -0300 Subject: wallet: add experimental SQLite record store --- .github/workflows/ci.yml | 1 + Makefile | 8 +- docs/storage.md | 19 ++++ src/Makefile | 10 +- src/main_gui.cpp | 3 +- src/makefile.mingw | 8 +- src/selftest.cpp | 108 +++++++++++++++++++- src/wallet_sqlite.cpp | 254 +++++++++++++++++++++++++++++++++++++++++++++++ src/wallet_sqlite.h | 40 ++++++++ 9 files changed, 439 insertions(+), 12 deletions(-) create mode 100644 src/wallet_sqlite.cpp create mode 100644 src/wallet_sqlite.h (limited to 'src/selftest.cpp') diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4045f4..e22b5a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,7 @@ jobs: mingw-w64-ucrt-x86_64-glfw mingw-w64-ucrt-x86_64-openssl mingw-w64-ucrt-x86_64-db + mingw-w64-ucrt-x86_64-sqlite3 mingw-w64-ucrt-x86_64-libsodium mingw-w64-ucrt-x86_64-nlohmann-json mingw-w64-ucrt-x86_64-boost diff --git a/Makefile b/Makefile index 60d1227..fab2507 100644 --- a/Makefile +++ b/Makefile @@ -43,10 +43,11 @@ deps-apt: @if command -v apt-get >/dev/null 2>&1; then \ $(SUDO) apt-get install -y build-essential cmake git pkg-config autoconf \ libtool libssl-dev libdb5.3++-dev libsodium-dev nlohmann-json3-dev \ - libboost-system-dev libglfw3-dev libgl-dev python3-pil clang; \ + libboost-system-dev libglfw3-dev libgl-dev libsqlite3-dev \ + python3-pil clang; \ else \ echo "Not an apt system — install: g++ cmake git autoconf libtool"; \ - echo " libssl libdb++ libsodium nlohmann-json boost glfw3 opengl python3-pil"; \ + echo " libssl libdb++ libsodium nlohmann-json boost glfw3 opengl sqlite3 python3-pil"; \ fi deps-secp256k1: @@ -113,7 +114,7 @@ img = ico.convert('RGBA'); \ img.save('/tmp/bitflash.AppDir/bitflash.png'); \ import shutil; shutil.copy('/tmp/bitflash.AppDir/bitflash.png', \ '/tmp/bitflash.AppDir/usr/share/icons/hicolor/256x256/apps/bitflash.png')" - @for lib in libsodium.so.23 libdb_cxx-5.3.so libglfw.so.3; do \ + @for lib in libsodium.so.23 libdb_cxx-5.3.so libsqlite3.so.0 libglfw.so.3; do \ path=$$(ldconfig -p | grep " $$lib " | awk '{print $$NF}' | head -1); \ [ -z "$$path" ] && path=$$(find /usr/lib /lib -name "$$lib" 2>/dev/null | head -1); \ [ -n "$$path" ] && cp -L "$$path" /tmp/bitflash.AppDir/usr/lib/ && echo " bundled $$lib" || echo " missing $$lib"; \ @@ -131,6 +132,7 @@ deps-windows: mingw-w64-ucrt-x86_64-glfw \ mingw-w64-ucrt-x86_64-openssl \ mingw-w64-ucrt-x86_64-db \ + mingw-w64-ucrt-x86_64-sqlite3 \ mingw-w64-ucrt-x86_64-libsodium \ mingw-w64-ucrt-x86_64-nlohmann-json \ mingw-w64-ucrt-x86_64-boost \ diff --git a/docs/storage.md b/docs/storage.md index c1cda45..f75f9b9 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -85,6 +85,25 @@ A future wallet backend must keep these properties: - recovery phrase encrypted and locked, requiring unlock before coverage can be audited. +## Experimental SQLite Wallet Store + +The SQLite wallet path starts as a byte-preserving record store, not as a new +wallet format: + +```sql +CREATE TABLE wallet_records ( + key BLOB PRIMARY KEY NOT NULL, + value BLOB NOT NULL +); +PRAGMA user_version=1; +``` + +Keys and values are the same serialized `CDataStream` bytes used by `wallet.dat`. +The first implementation is exercised only by `-selftest=wallet-sqlite`; the +runtime wallet still opens Berkeley DB. The purpose is to prove schema creation, +raw record write/read, close/reopen behavior, and value preservation before any +BDB-to-SQLite migration or runtime backend flag exists. + ## `blkindex.dat` `blkindex.dat` is accessed through `CTxDB`. diff --git a/src/Makefile b/src/Makefile index 19ad33a..cfa40f6 100644 --- a/src/Makefile +++ b/src/Makefile @@ -40,7 +40,7 @@ FUZZ_LDFLAGS = -fsanitize=fuzzer,address,undefined LIBS = -L$(SECP_LIB) -lsecp256k1 \ -L$(RANDOMX_DIR)/build -lrandomx \ - -lsodium -lssl -lcrypto -ldb_cxx -lpthread \ + -lsodium -lssl -lcrypto -ldb_cxx -lsqlite3 -lpthread \ -lglfw -lGL # Every header a .cpp here includes has to be listed, not just the interesting @@ -53,7 +53,7 @@ LIBS = -L$(SECP_LIB) -lsecp256k1 \ HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ key.h bignum.h script.h db.h base58.h net.h nostr.h \ randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h \ - selftest.h walletcmd.h compat.h sockcount.h proxy.h tor.h font_roboto.h + selftest.h walletcmd.h wallet_sqlite.h compat.h sockcount.h proxy.h tor.h font_roboto.h IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ obj/imgui_widgets.o obj/imgui_impl_glfw.o obj/imgui_impl_opengl3.o @@ -61,7 +61,8 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ - obj/selftest.o obj/bip32.o obj/walletcmd.o obj/proxy.o obj/tor.o + obj/selftest.o obj/bip32.o obj/walletcmd.o obj/wallet_sqlite.o \ + obj/proxy.o obj/tor.o GUI_OBJS = obj/gui.o obj/main_gui.o @@ -116,7 +117,7 @@ bitflash: $(IMGUI_OBJS) $(NODE_OBJS) $(GUI_OBJS) NODE_LIBS = -L$(SECP_LIB) -lsecp256k1 \ -L$(RANDOMX_DIR)/build -lrandomx \ - -lsodium -lssl -lcrypto -ldb_cxx -lpthread + -lsodium -lssl -lcrypto -ldb_cxx -lsqlite3 -lpthread NODE_ONLY_OBJS = $(patsubst obj/%,obj-node/%,$(NODE_OBJS)) \ obj-node/main_gui.o obj-node/headless.o @@ -187,6 +188,7 @@ tests: test_bip32 bitflash-node ./bitflash-node -selftest=wallet-keypool ./bitflash-node -selftest=wallet-hd ./bitflash-node -selftest=wallet-format + ./bitflash-node -selftest=wallet-sqlite ./bitflash-node -selftest=wallet-crypto ./bitflash-node -selftest=wallet-encrypt ./bitflash-node -selftest=net-message diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 705b4ef..965c818 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -114,7 +114,8 @@ static void PrintUsage() printf(" /gen\n"); printf(" /nogui or /daemon\n"); printf(" /selftest=wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity,\n"); - printf(" db-env-reopen, wallet-crypto, wallet-encrypt, wallet-portability,\n"); + printf(" db-env-reopen, wallet-sqlite, wallet-crypto, wallet-encrypt,\n"); + printf(" wallet-portability,\n"); printf(" net-message,\n"); printf(" consensus-limits, pool-stratum,\n"); printf(" parse-money, socks5-proxy, or managed-tor\n"); diff --git a/src/makefile.mingw b/src/makefile.mingw index 333be96..2ff4e11 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -24,7 +24,7 @@ CXXFLAGS = -std=gnu++14 -O2 $(WARNFLAGS) -fpermissive -pthread \ LIBS = -L$(DEPS)/lib -lsecp256k1 \ -L$(RANDOMX)/build -lrandomx \ - -lsodium -lssl -lcrypto -ldb_cxx -lpthread \ + -lsodium -lssl -lcrypto -ldb_cxx -lsqlite3 -lpthread \ -LC:/msys64/ucrt64/lib \ -lglfw3 -lopengl32 -lgdi32 \ -lkernel32 -luser32 -lcomdlg32 -lwinspool -lwinmm \ @@ -37,7 +37,8 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ - obj/selftest.o obj/bip32.o obj/walletcmd.o obj/proxy.o obj/tor.o + obj/selftest.o obj/bip32.o obj/walletcmd.o obj/wallet_sqlite.o \ + obj/proxy.o obj/tor.o GUI_OBJS = obj/gui.o obj/main_gui.o obj/bitflash_res.o @@ -51,7 +52,7 @@ GUI_OBJS = obj/gui.o obj/main_gui.o obj/bitflash_res.o HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ key.h bignum.h script.h db.h base58.h net.h nostr.h \ randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h \ - selftest.h walletcmd.h sockcount.h proxy.h tor.h compat.h font_roboto.h + selftest.h walletcmd.h wallet_sqlite.h sockcount.h proxy.h tor.h compat.h font_roboto.h all: bitflash.exe @@ -107,6 +108,7 @@ tests: test_bip32.exe bitflash.exe ./bitflash.exe -selftest=wallet-keypool ./bitflash.exe -selftest=wallet-hd ./bitflash.exe -selftest=wallet-format + ./bitflash.exe -selftest=wallet-sqlite ./bitflash.exe -selftest=wallet-crypto ./bitflash.exe -selftest=wallet-encrypt ./bitflash.exe -selftest=net-message diff --git a/src/selftest.cpp b/src/selftest.cpp index 31c168f..f9c4a8e 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -13,6 +13,7 @@ #include "sockcount.h" #include "tor.h" #include "walletcmd.h" +#include "wallet_sqlite.h" extern int RunPoolStratumSelfTest(); @@ -62,6 +63,11 @@ static std::string HexStrLocal(const std::vector& v) return out; } +static vector DataStreamBytes(const CDataStream& ss) +{ + return vector(ss.begin(), ss.end()); +} + static bool MakeTempDir(std::string& out) { #ifdef _WIN32 @@ -1030,6 +1036,104 @@ static int RunDbEnvReopenSelfTest() return nFail == 0 ? 0 : 1; } +static int RunWalletSQLiteSelfTest() +{ + fflush(stdout); + printf("wallet-sqlite self-test\n"); + + std::string tmp; + if (!MakeTempDir(tmp)) + { + printf(" FAIL could not create a temporary data directory\n"); + return 1; + } + + int nFail = 0; + string strPath = tmp + "/wallet.sqlite"; + string strError; + + try + { + CDataStream ssKeySetting(SER_DISK); + ssKeySetting << make_pair(string("setting"), string("sqlite-roundtrip")); + CDataStream ssValueSetting(SER_DISK); + ssValueSetting << (int64)424242; + + vector vchPubKey; + vchPubKey.push_back(0x04); + for (int i = 0; i < 64; i++) + vchPubKey.push_back((unsigned char)i); + CDataStream ssKeyDefault(SER_DISK); + ssKeyDefault << string("defaultkey"); + CDataStream ssValueDefault(SER_DISK); + ssValueDefault << vchPubKey; + + { + CWalletDBSQLite db; + nFail += Check(db.Open(strPath, strError), + "SQLite wallet opens and creates schema") ? 0 : 1; + nFail += Check(db.BeginTransaction(strError), + "SQLite wallet begins a write transaction") ? 0 : 1; + nFail += Check(db.WriteRecord(DataStreamBytes(ssKeySetting), + DataStreamBytes(ssValueSetting), + strError), + "SQLite wallet writes a serialized setting record") ? 0 : 1; + nFail += Check(db.WriteRecord(DataStreamBytes(ssKeyDefault), + DataStreamBytes(ssValueDefault), + strError), + "SQLite wallet writes a serialized defaultkey record") ? 0 : 1; + nFail += Check(db.CommitTransaction(strError), + "SQLite wallet commits a write transaction") ? 0 : 1; + int nCount = 0; + nFail += Check(db.CountRecords(nCount, strError) && nCount == 2, + "SQLite wallet counts raw records") ? 0 : 1; + } + + { + CWalletDBSQLite db; + nFail += Check(db.Open(strPath, strError), + "SQLite wallet reopens") ? 0 : 1; + vector vchRead; + nFail += Check(db.ReadRecord(DataStreamBytes(ssKeySetting), + vchRead, + strError), + "SQLite wallet reads a serialized setting record") ? 0 : 1; + int64 nReadValue = 0; + CDataStream ssRead(vchRead, SER_DISK); + ssRead >> nReadValue; + nFail += Check(!ssRead.fail() && nReadValue == 424242, + "SQLite wallet preserves serialized values byte-for-byte") ? 0 : 1; + + vchRead.clear(); + nFail += Check(db.ReadRecord(DataStreamBytes(ssKeyDefault), + vchRead, + strError), + "SQLite wallet reads a serialized defaultkey record") ? 0 : 1; + vector vchReadPubKey; + CDataStream ssReadDefault(vchRead, SER_DISK); + ssReadDefault >> vchReadPubKey; + nFail += Check(!ssReadDefault.fail() && vchReadPubKey == vchPubKey, + "SQLite wallet preserves public key blobs") ? 0 : 1; + } + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + RemoveTree(tmp); + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + static void ClearWalletRuntimeForTest() { CRITICAL_BLOCK(cs_mapKeys) @@ -2289,6 +2393,8 @@ int RunSelfTest(const std::string& name) return RunWalletStorageSanitySelfTest(); if (name == "db-env-reopen") return RunDbEnvReopenSelfTest(); + if (name == "wallet-sqlite") + return RunWalletSQLiteSelfTest(); if (name == "wallet-crypto") return RunWalletCryptoSelfTest(); if (name == "wallet-encrypt") @@ -2309,6 +2415,6 @@ int RunSelfTest(const std::string& name) return RunManagedTorSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, db-env-reopen, wallet-crypto, wallet-encrypt, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, db-env-reopen, wallet-sqlite, wallet-crypto, wallet-encrypt, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); return 1; } diff --git a/src/wallet_sqlite.cpp b/src/wallet_sqlite.cpp new file mode 100644 index 0000000..4104ac6 --- /dev/null +++ b/src/wallet_sqlite.cpp @@ -0,0 +1,254 @@ +// Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. + +#include "headers.h" +#include "wallet_sqlite.h" + +static void SetSQLiteError(sqlite3* pdb, const string& strContext, + string& strError) +{ + if (pdb) + strError = strContext + ": " + sqlite3_errmsg(pdb); + else + strError = strContext; +} + +CWalletDBSQLite::CWalletDBSQLite() : pdb(NULL) +{ +} + +CWalletDBSQLite::~CWalletDBSQLite() +{ + Close(); +} + +bool CWalletDBSQLite::Open(const string& strPath, string& strError) +{ + Close(); + + int ret = sqlite3_open_v2(strPath.c_str(), &pdb, + SQLITE_OPEN_READWRITE | + SQLITE_OPEN_CREATE | + SQLITE_OPEN_FULLMUTEX, + NULL); + if (ret != SQLITE_OK) + { + SetSQLiteError(pdb, "cannot open SQLite wallet", strError); + Close(); + return false; + } + + sqlite3_busy_timeout(pdb, 5000); + + if (!Exec("PRAGMA journal_mode=WAL;", strError)) + { + Close(); + return false; + } + if (!Exec("PRAGMA synchronous=FULL;", strError)) + { + Close(); + return false; + } + if (!InitSchema(strError)) + { + Close(); + return false; + } + return true; +} + +void CWalletDBSQLite::Close() +{ + if (pdb) + { + sqlite3_close(pdb); + pdb = NULL; + } +} + +bool CWalletDBSQLite::BeginTransaction(string& strError) +{ + if (!pdb) + { + strError = "SQLite wallet is not open"; + return false; + } + return Exec("BEGIN IMMEDIATE;", strError); +} + +bool CWalletDBSQLite::CommitTransaction(string& strError) +{ + if (!pdb) + { + strError = "SQLite wallet is not open"; + return false; + } + return Exec("COMMIT;", strError); +} + +bool CWalletDBSQLite::RollbackTransaction(string& strError) +{ + if (!pdb) + { + strError = "SQLite wallet is not open"; + return false; + } + return Exec("ROLLBACK;", strError); +} + +bool CWalletDBSQLite::Exec(const char* pszSql, string& strError) +{ + char* pszErr = NULL; + int ret = sqlite3_exec(pdb, pszSql, NULL, NULL, &pszErr); + if (ret != SQLITE_OK) + { + if (pszErr) + { + strError = pszErr; + sqlite3_free(pszErr); + } + else + SetSQLiteError(pdb, "SQLite exec failed", strError); + return false; + } + return true; +} + +bool CWalletDBSQLite::InitSchema(string& strError) +{ + return Exec("CREATE TABLE IF NOT EXISTS wallet_records (" + "key BLOB PRIMARY KEY NOT NULL," + "value BLOB NOT NULL" + ");", + strError) && + Exec("PRAGMA user_version=1;", strError); +} + +bool CWalletDBSQLite::WriteRecord(const vector& vchKey, + const vector& vchValue, + string& strError) +{ + if (!pdb) + { + strError = "SQLite wallet is not open"; + return false; + } + if (vchKey.empty()) + { + strError = "SQLite wallet record key is empty"; + return false; + } + + sqlite3_stmt* stmt = NULL; + const char* pszSql = + "INSERT OR REPLACE INTO wallet_records(key, value) VALUES(?, ?);"; + int ret = sqlite3_prepare_v2(pdb, pszSql, -1, &stmt, NULL); + if (ret != SQLITE_OK) + { + SetSQLiteError(pdb, "cannot prepare SQLite wallet write", strError); + return false; + } + + ret = sqlite3_bind_blob(stmt, 1, &vchKey[0], (int)vchKey.size(), + SQLITE_TRANSIENT); + if (ret == SQLITE_OK) + ret = sqlite3_bind_blob(stmt, 2, + vchValue.empty() ? NULL : &vchValue[0], + (int)vchValue.size(), + SQLITE_TRANSIENT); + if (ret == SQLITE_OK) + ret = sqlite3_step(stmt); + + bool fOk = (ret == SQLITE_DONE); + if (!fOk) + SetSQLiteError(pdb, "cannot write SQLite wallet record", strError); + sqlite3_finalize(stmt); + return fOk; +} + +bool CWalletDBSQLite::ReadRecord(const vector& vchKey, + vector& vchValueRet, + string& strError) +{ + vchValueRet.clear(); + if (!pdb) + { + strError = "SQLite wallet is not open"; + return false; + } + if (vchKey.empty()) + { + strError = "SQLite wallet record key is empty"; + return false; + } + + sqlite3_stmt* stmt = NULL; + const char* pszSql = "SELECT value FROM wallet_records WHERE key = ?;"; + int ret = sqlite3_prepare_v2(pdb, pszSql, -1, &stmt, NULL); + if (ret != SQLITE_OK) + { + SetSQLiteError(pdb, "cannot prepare SQLite wallet read", strError); + return false; + } + + ret = sqlite3_bind_blob(stmt, 1, &vchKey[0], (int)vchKey.size(), + SQLITE_TRANSIENT); + if (ret != SQLITE_OK) + { + SetSQLiteError(pdb, "cannot bind SQLite wallet key", strError); + sqlite3_finalize(stmt); + return false; + } + + ret = sqlite3_step(stmt); + if (ret == SQLITE_ROW) + { + const unsigned char* pch = + (const unsigned char*)sqlite3_column_blob(stmt, 0); + int nBytes = sqlite3_column_bytes(stmt, 0); + if (nBytes > 0 && pch) + vchValueRet.assign(pch, pch + nBytes); + sqlite3_finalize(stmt); + return true; + } + + if (ret == SQLITE_DONE) + strError = "SQLite wallet record not found"; + else + SetSQLiteError(pdb, "cannot read SQLite wallet record", strError); + sqlite3_finalize(stmt); + return false; +} + +bool CWalletDBSQLite::CountRecords(int& nRecordsRet, string& strError) +{ + nRecordsRet = 0; + if (!pdb) + { + strError = "SQLite wallet is not open"; + return false; + } + + sqlite3_stmt* stmt = NULL; + int ret = sqlite3_prepare_v2(pdb, + "SELECT COUNT(*) FROM wallet_records;", + -1, &stmt, NULL); + if (ret != SQLITE_OK) + { + SetSQLiteError(pdb, "cannot prepare SQLite wallet count", strError); + return false; + } + + ret = sqlite3_step(stmt); + if (ret != SQLITE_ROW) + { + SetSQLiteError(pdb, "cannot count SQLite wallet records", strError); + sqlite3_finalize(stmt); + return false; + } + + nRecordsRet = sqlite3_column_int(stmt, 0); + sqlite3_finalize(stmt); + return true; +} diff --git a/src/wallet_sqlite.h b/src/wallet_sqlite.h new file mode 100644 index 0000000..9812d3a --- /dev/null +++ b/src/wallet_sqlite.h @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. + +#ifndef BITFLASH_WALLET_SQLITE_H +#define BITFLASH_WALLET_SQLITE_H + +#include + +class CWalletDBSQLite +{ +public: + CWalletDBSQLite(); + ~CWalletDBSQLite(); + + bool Open(const string& strPath, string& strError); + void Close(); + + bool BeginTransaction(string& strError); + bool CommitTransaction(string& strError); + bool RollbackTransaction(string& strError); + + bool WriteRecord(const vector& vchKey, + const vector& vchValue, + string& strError); + bool ReadRecord(const vector& vchKey, + vector& vchValueRet, + string& strError); + bool CountRecords(int& nRecordsRet, string& strError); + +private: + sqlite3* pdb; + + bool Exec(const char* pszSql, string& strError); + bool InitSchema(string& strError); + + CWalletDBSQLite(const CWalletDBSQLite&); + void operator=(const CWalletDBSQLite&); +}; + +#endif -- cgit v1.2.3 From 59b9d11015f0e3a2475d0750fc534583c2ca9f77 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 14 Aug 2026 15:56:12 -0300 Subject: wallet: export BDB records to SQLite --- docs/storage.md | 6 ++ src/Makefile | 1 + src/main_gui.cpp | 15 ++++- src/makefile.mingw | 1 + src/selftest.cpp | 148 +++++++++++++++++++++++++++++++++++++++++++++++++- src/wallet_sqlite.cpp | 10 ++++ src/wallet_sqlite.h | 1 + src/walletcmd.cpp | 97 +++++++++++++++++++++++++++++++++ src/walletcmd.h | 5 ++ 9 files changed, 281 insertions(+), 3 deletions(-) (limited to 'src/selftest.cpp') diff --git a/docs/storage.md b/docs/storage.md index f75f9b9..bb414b5 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -104,6 +104,12 @@ runtime wallet still opens Berkeley DB. The purpose is to prove schema creation, raw record write/read, close/reopen behavior, and value preservation before any BDB-to-SQLite migration or runtime backend flag exists. +`-walletsqliteexport=FILE` is the next staging tool. It streams every raw +`wallet.dat` record through `ScanWalletRecords()` and writes those same key and +value bytes into the SQLite table inside one transaction. It refuses to +overwrite an existing export and still does not change the runtime wallet +backend. + ## `blkindex.dat` `blkindex.dat` is accessed through `CTxDB`. diff --git a/src/Makefile b/src/Makefile index cfa40f6..b83ff58 100644 --- a/src/Makefile +++ b/src/Makefile @@ -189,6 +189,7 @@ tests: test_bip32 bitflash-node ./bitflash-node -selftest=wallet-hd ./bitflash-node -selftest=wallet-format ./bitflash-node -selftest=wallet-sqlite + ./bitflash-node -selftest=wallet-sqlite-migration ./bitflash-node -selftest=wallet-crypto ./bitflash-node -selftest=wallet-encrypt ./bitflash-node -selftest=net-message diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 965c818..6e89270 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -114,8 +114,8 @@ static void PrintUsage() printf(" /gen\n"); printf(" /nogui or /daemon\n"); printf(" /selftest=wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity,\n"); - printf(" db-env-reopen, wallet-sqlite, wallet-crypto, wallet-encrypt,\n"); - printf(" wallet-portability,\n"); + printf(" db-env-reopen, wallet-sqlite, wallet-sqlite-migration,\n"); + printf(" wallet-crypto, wallet-encrypt, wallet-portability,\n"); printf(" net-message,\n"); printf(" consensus-limits, pool-stratum,\n"); printf(" parse-money, socks5-proxy, or managed-tor\n"); @@ -181,6 +181,8 @@ static void PrintUsage() printf(" (write the same audit as deterministic JSON)\n"); printf(" /walletstoragecheck (fail closed on inconsistent or unsafe\n"); printf(" wallet.dat storage state)\n"); + printf(" /walletsqliteexport=FILE (copy raw wallet.dat records into an\n"); + printf(" experimental SQLite store, then exit)\n"); printf(" /rescan (walk the chain for coins this wallet owns\n"); printf(" but never recorded, then exit)\n"); printf("\n"); @@ -529,6 +531,15 @@ int main(int argc, char* argv[]) return nRet; } + string strWalletSQLiteExport = + argval2(argc, argv, "/walletsqliteexport", "-walletsqliteexport"); + if (!strWalletSQLiteExport.empty()) + { + int nRet = CmdWalletSQLiteExport(strWalletSQLiteExport); + DBFlush(true); + return nRet; + } + // Berkeley DB reports failure by throwing, and CDB's constructor lets it // through. Nothing on this path caught anything, so an unreadable // blkindex.dat or wallet.dat -- one written by another platform's Berkeley diff --git a/src/makefile.mingw b/src/makefile.mingw index 2ff4e11..14e6b32 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -109,6 +109,7 @@ tests: test_bip32.exe bitflash.exe ./bitflash.exe -selftest=wallet-hd ./bitflash.exe -selftest=wallet-format ./bitflash.exe -selftest=wallet-sqlite + ./bitflash.exe -selftest=wallet-sqlite-migration ./bitflash.exe -selftest=wallet-crypto ./bitflash.exe -selftest=wallet-encrypt ./bitflash.exe -selftest=net-message diff --git a/src/selftest.cpp b/src/selftest.cpp index f9c4a8e..7a54271 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1134,6 +1134,150 @@ static int RunWalletSQLiteSelfTest() return nFail == 0 ? 0 : 1; } +class CWalletRecordMapVisitor : public CWalletRecordVisitor +{ +public: + std::map, vector >& mapRecords; + + explicit CWalletRecordMapVisitor( + std::map, vector >& mapRecordsIn) + : mapRecords(mapRecordsIn) { } + + bool VisitWalletRecord(const CDataStream& ssKey, + const CDataStream& ssValue, + string& strErrorRet) + { + vector vchKey = DataStreamBytes(ssKey); + if (mapRecords.count(vchKey)) + { + strErrorRet = "duplicate serialized wallet key"; + return false; + } + mapRecords[vchKey] = DataStreamBytes(ssValue); + return true; + } +}; + +static int RunWalletSQLiteMigrationSelfTest() +{ + fflush(stdout); + printf("wallet-sqlite-migration self-test\n"); + + std::string tmp; + std::string cwd; + if (!MakeTempDir(tmp)) + { + printf(" FAIL could not create a temporary data directory\n"); + return 1; + } + if (!GetCurrentDir(cwd)) + { + printf(" FAIL could not read current directory\n"); + RemoveTree(tmp); + return 1; + } + + int nFail = 0; + printf(" temp root: %s\n", tmp.c_str()); + + try + { +#ifdef _WIN32 + string strExe = cwd + "\\bitflash.exe"; +#else + string strExe = cwd + "/bitflash-node"; +#endif + string strWalletDir = tmp + "/wallet"; + nFail += Check(MakeDirLocal(strWalletDir), + "temporary wallet directory can be created") ? 0 : 1; + + string strCreateOut = tmp + "/create.txt"; + vector vCreateArgs; + vCreateArgs.push_back("-datadir=" + strWalletDir); + vCreateArgs.push_back("-nomanagedtor"); + vCreateArgs.push_back("-nogui"); + vCreateArgs.push_back("-newphrase"); + int nCreateRet = RunBitflashChild(strExe, vCreateArgs, NULL, &strCreateOut); + nFail += Check(nCreateRet == 0 && + FileContainsText(strCreateOut, "Write these twelve words down"), + "a phrase wallet can be created for SQLite export") ? 0 : 1; + + string strSQLite = tmp + "/wallet.sqlite"; + string strExportOut = tmp + "/sqlite-export.txt"; + vector vExportArgs; + vExportArgs.push_back("-datadir=" + strWalletDir); + vExportArgs.push_back("-nomanagedtor"); + vExportArgs.push_back("-nogui"); + vExportArgs.push_back("-walletsqliteexport=" + strSQLite); + int nExportRet = RunBitflashChild(strExe, vExportArgs, NULL, &strExportOut); + nFail += Check(nExportRet == 0 && + FileExists(strSQLite.c_str()) && + FileContainsText(strExportOut, "SQLite wallet export written"), + "wallet.dat can be exported to SQLite") ? 0 : 1; + + string strOverwriteOut = tmp + "/sqlite-export-overwrite.txt"; + int nOverwriteRet = + RunBitflashChild(strExe, vExportArgs, NULL, &strOverwriteOut); + nFail += Check(nOverwriteRet != 0 && + FileContainsText(strOverwriteOut, + "Refusing to overwrite existing SQLite wallet export"), + "SQLite wallet export refuses to overwrite") ? 0 : 1; + remove((strSQLite + "-wal").c_str()); + remove((strSQLite + "-shm").c_str()); + + std::map, vector > mapBDB; + string strOldDataDir = strSetDataDir; + string strScanError; + strSetDataDir = strWalletDir; + CWalletRecordMapVisitor visitor(mapBDB); + bool fScanOk = ScanWalletRecords(visitor, strScanError); + DBFlush(true); + strSetDataDir = strOldDataDir; + nFail += Check(fScanOk && !mapBDB.empty(), + "wallet.dat records can be scanned for comparison") ? 0 : 1; + + CWalletDBSQLite db; + string strError; + nFail += Check(db.Open(strSQLite, strError), + "exported SQLite wallet opens") ? 0 : 1; + int nSQLiteRecords = 0; + nFail += Check(db.CountRecords(nSQLiteRecords, strError) && + nSQLiteRecords == (int)mapBDB.size(), + "SQLite export has the same record count as wallet.dat") ? 0 : 1; + + bool fAllMatch = true; + for (std::map, vector >::const_iterator it = + mapBDB.begin(); it != mapBDB.end(); ++it) + { + vector vchSQLiteValue; + if (!db.ReadRecord(it->first, vchSQLiteValue, strError) || + vchSQLiteValue != it->second) + { + fAllMatch = false; + break; + } + } + nFail += Check(fAllMatch, + "SQLite export preserves every wallet.dat key/value byte-for-byte") ? 0 : 1; + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + RemoveTree(tmp); + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + static void ClearWalletRuntimeForTest() { CRITICAL_BLOCK(cs_mapKeys) @@ -2395,6 +2539,8 @@ int RunSelfTest(const std::string& name) return RunDbEnvReopenSelfTest(); if (name == "wallet-sqlite") return RunWalletSQLiteSelfTest(); + if (name == "wallet-sqlite-migration") + return RunWalletSQLiteMigrationSelfTest(); if (name == "wallet-crypto") return RunWalletCryptoSelfTest(); if (name == "wallet-encrypt") @@ -2415,6 +2561,6 @@ int RunSelfTest(const std::string& name) return RunManagedTorSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, db-env-reopen, wallet-sqlite, wallet-crypto, wallet-encrypt, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, db-env-reopen, wallet-sqlite, wallet-sqlite-migration, wallet-crypto, wallet-encrypt, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); return 1; } diff --git a/src/wallet_sqlite.cpp b/src/wallet_sqlite.cpp index 4104ac6..4369af6 100644 --- a/src/wallet_sqlite.cpp +++ b/src/wallet_sqlite.cpp @@ -97,6 +97,16 @@ bool CWalletDBSQLite::RollbackTransaction(string& strError) return Exec("ROLLBACK;", strError); } +bool CWalletDBSQLite::Checkpoint(string& strError) +{ + if (!pdb) + { + strError = "SQLite wallet is not open"; + return false; + } + return Exec("PRAGMA wal_checkpoint(TRUNCATE);", strError); +} + bool CWalletDBSQLite::Exec(const char* pszSql, string& strError) { char* pszErr = NULL; diff --git a/src/wallet_sqlite.h b/src/wallet_sqlite.h index 9812d3a..5ef862d 100644 --- a/src/wallet_sqlite.h +++ b/src/wallet_sqlite.h @@ -18,6 +18,7 @@ public: bool BeginTransaction(string& strError); bool CommitTransaction(string& strError); bool RollbackTransaction(string& strError); + bool Checkpoint(string& strError); bool WriteRecord(const vector& vchKey, const vector& vchValue, diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index 6fb09a3..20e7e33 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -12,6 +12,7 @@ #include "headers_core.h" #include "bip32.h" #include "walletcmd.h" +#include "wallet_sqlite.h" #undef printf @@ -790,6 +791,102 @@ int CmdWalletStorageAudit(const std::string& strJsonOut) return 0; } +static vector DataStreamBytesLocal(const CDataStream& ss) +{ + return vector(ss.begin(), ss.end()); +} + +class CWalletSQLiteExportVisitor : public CWalletRecordVisitor +{ +public: + CWalletDBSQLite& db; + int& nCopied; + + CWalletSQLiteExportVisitor(CWalletDBSQLite& dbIn, int& nCopiedIn) + : db(dbIn), nCopied(nCopiedIn) { } + + bool VisitWalletRecord(const CDataStream& ssKey, + const CDataStream& ssValue, + string& strErrorRet) + { + if (!db.WriteRecord(DataStreamBytesLocal(ssKey), + DataStreamBytesLocal(ssValue), + strErrorRet)) + return false; + nCopied++; + return true; + } +}; + +int CmdWalletSQLiteExport(const std::string& strDest) +{ + AttachTerminal(); + + if (strDest.empty()) + { + fprintf(stderr, "Missing SQLite wallet export path.\n"); + return 1; + } + if (FileExists(strDest.c_str())) + { + fprintf(stderr, "Refusing to overwrite existing SQLite wallet export: %s\n", + strDest.c_str()); + return 1; + } + + string strError; + CWalletDBSQLite db; + if (!db.Open(strDest, strError)) + { + fprintf(stderr, "Cannot open SQLite wallet export: %s\n", strError.c_str()); + return 1; + } + if (!db.BeginTransaction(strError)) + { + fprintf(stderr, "Cannot begin SQLite wallet export: %s\n", strError.c_str()); + return 1; + } + + int nCopied = 0; + CWalletSQLiteExportVisitor visitor(db, nCopied); + if (!ScanWalletRecords(visitor, strError)) + { + string strRollbackError; + db.RollbackTransaction(strRollbackError); + fprintf(stderr, "Cannot export wallet.dat to SQLite: %s\n", strError.c_str()); + return 1; + } + if (!db.CommitTransaction(strError)) + { + fprintf(stderr, "Cannot commit SQLite wallet export: %s\n", strError.c_str()); + return 1; + } + if (!db.Checkpoint(strError)) + { + fprintf(stderr, "Cannot checkpoint SQLite wallet export: %s\n", strError.c_str()); + return 1; + } + + int nCount = 0; + if (!db.CountRecords(nCount, strError)) + { + fprintf(stderr, "Cannot verify SQLite wallet export count: %s\n", + strError.c_str()); + return 1; + } + if (nCount != nCopied) + { + fprintf(stderr, "SQLite wallet export count mismatch: copied %d, stored %d\n", + nCopied, nCount); + return 1; + } + + printf("SQLite wallet export written to %s\n", strDest.c_str()); + printf(" records copied: %d\n", nCopied); + fflush(stdout); + return 0; +} + int CmdEncryptWallet(const std::string& strPassphrase) { AttachTerminal(); diff --git a/src/walletcmd.h b/src/walletcmd.h index 6077669..b1a29d2 100644 --- a/src/walletcmd.h +++ b/src/walletcmd.h @@ -70,6 +70,11 @@ int CmdWalletStorageAudit(const std::string& strJsonOut = ""); // or migration-unsafe mix of storage records. int CmdWalletStorageCheck(); +// Diagnostic/migration staging: copy every raw wallet.dat record into an +// experimental SQLite key/value store, preserving serialized bytes. This does +// not change which backend the runtime wallet opens. +int CmdWalletSQLiteExport(const std::string& strDest); + // Rewrite wallet.dat so private keys and the HD seed are encrypted with the // given passphrase. Returns 0 on success and exits without starting the node. int CmdEncryptWallet(const std::string& strPassphrase); -- cgit v1.2.3 From df84b82162b38dcd57114510b9651af5d85f04cb Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 14 Aug 2026 16:20:41 -0300 Subject: wallet: scan SQLite wallet records --- docs/storage.md | 4 +++- src/selftest.cpp | 6 ++++++ src/wallet_sqlite.cpp | 54 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/wallet_sqlite.h | 3 +++ 4 files changed, 66 insertions(+), 1 deletion(-) (limited to 'src/selftest.cpp') diff --git a/docs/storage.md b/docs/storage.md index bb414b5..2d748fa 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -108,7 +108,9 @@ BDB-to-SQLite migration or runtime backend flag exists. `wallet.dat` record through `ScanWalletRecords()` and writes those same key and value bytes into the SQLite table inside one transaction. It refuses to overwrite an existing export and still does not change the runtime wallet -backend. +backend. `CWalletDBSQLite::ScanRecords()` can stream those exported records back +through the same visitor shape, so tests can compare BDB and SQLite record +streams without introducing a SQLite runtime wallet loader yet. ## `blkindex.dat` diff --git a/src/selftest.cpp b/src/selftest.cpp index 7a54271..408dd41 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1259,6 +1259,12 @@ static int RunWalletSQLiteMigrationSelfTest() } nFail += Check(fAllMatch, "SQLite export preserves every wallet.dat key/value byte-for-byte") ? 0 : 1; + + std::map, vector > mapSQLite; + CWalletRecordMapVisitor sqliteVisitor(mapSQLite); + nFail += Check(db.ScanRecords(sqliteVisitor, strError) && + mapSQLite == mapBDB, + "SQLite wallet scanner streams every exported record byte-for-byte") ? 0 : 1; } catch (const std::exception& e) { diff --git a/src/wallet_sqlite.cpp b/src/wallet_sqlite.cpp index 4369af6..8489cac 100644 --- a/src/wallet_sqlite.cpp +++ b/src/wallet_sqlite.cpp @@ -231,6 +231,60 @@ bool CWalletDBSQLite::ReadRecord(const vector& vchKey, return false; } +bool CWalletDBSQLite::ScanRecords(CWalletRecordVisitor& visitor, + string& strError) +{ + if (!pdb) + { + strError = "SQLite wallet is not open"; + return false; + } + + sqlite3_stmt* stmt = NULL; + const char* pszSql = "SELECT key, value FROM wallet_records ORDER BY rowid;"; + int ret = sqlite3_prepare_v2(pdb, pszSql, -1, &stmt, NULL); + if (ret != SQLITE_OK) + { + SetSQLiteError(pdb, "cannot prepare SQLite wallet scan", strError); + return false; + } + + while ((ret = sqlite3_step(stmt)) == SQLITE_ROW) + { + const unsigned char* pchKey = + (const unsigned char*)sqlite3_column_blob(stmt, 0); + int nKeyBytes = sqlite3_column_bytes(stmt, 0); + const unsigned char* pchValue = + (const unsigned char*)sqlite3_column_blob(stmt, 1); + int nValueBytes = sqlite3_column_bytes(stmt, 1); + + vector vchKey; + vector vchValue; + if (nKeyBytes > 0 && pchKey) + vchKey.assign(pchKey, pchKey + nKeyBytes); + if (nValueBytes > 0 && pchValue) + vchValue.assign(pchValue, pchValue + nValueBytes); + + CDataStream ssKey(vchKey, SER_DISK); + CDataStream ssValue(vchValue, SER_DISK); + if (!visitor.VisitWalletRecord(ssKey, ssValue, strError)) + { + sqlite3_finalize(stmt); + return false; + } + } + + if (ret != SQLITE_DONE) + { + SetSQLiteError(pdb, "cannot scan SQLite wallet records", strError); + sqlite3_finalize(stmt); + return false; + } + + sqlite3_finalize(stmt); + return true; +} + bool CWalletDBSQLite::CountRecords(int& nRecordsRet, string& strError) { nRecordsRet = 0; diff --git a/src/wallet_sqlite.h b/src/wallet_sqlite.h index 5ef862d..385a340 100644 --- a/src/wallet_sqlite.h +++ b/src/wallet_sqlite.h @@ -6,6 +6,8 @@ #include +class CWalletRecordVisitor; + class CWalletDBSQLite { public: @@ -26,6 +28,7 @@ public: bool ReadRecord(const vector& vchKey, vector& vchValueRet, string& strError); + bool ScanRecords(CWalletRecordVisitor& visitor, string& strError); bool CountRecords(int& nRecordsRet, string& strError); private: -- cgit v1.2.3 From 99c6b5f2971da51cca1c739b5e982ae2022acc85 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 14 Aug 2026 16:34:43 -0300 Subject: wallet: verify SQLite wallet exports --- docs/storage.md | 2 + src/main_gui.cpp | 11 ++++++ src/selftest.cpp | 41 +++++++++++++++++++++ src/walletcmd.cpp | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/walletcmd.h | 4 ++ 5 files changed, 165 insertions(+) (limited to 'src/selftest.cpp') diff --git a/docs/storage.md b/docs/storage.md index 2d748fa..0a441bc 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -111,6 +111,8 @@ overwrite an existing export and still does not change the runtime wallet backend. `CWalletDBSQLite::ScanRecords()` can stream those exported records back through the same visitor shape, so tests can compare BDB and SQLite record streams without introducing a SQLite runtime wallet loader yet. +`-walletsqliteverify=FILE` compares the current `wallet.dat` stream with a +SQLite export and prints only record counts and mismatch counts. ## `blkindex.dat` diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 6e89270..caacf13 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -183,6 +183,8 @@ static void PrintUsage() printf(" wallet.dat storage state)\n"); printf(" /walletsqliteexport=FILE (copy raw wallet.dat records into an\n"); printf(" experimental SQLite store, then exit)\n"); + printf(" /walletsqliteverify=FILE (compare wallet.dat with a SQLite export,\n"); + printf(" printing counts only, then exit)\n"); printf(" /rescan (walk the chain for coins this wallet owns\n"); printf(" but never recorded, then exit)\n"); printf("\n"); @@ -540,6 +542,15 @@ int main(int argc, char* argv[]) return nRet; } + string strWalletSQLiteVerify = + argval2(argc, argv, "/walletsqliteverify", "-walletsqliteverify"); + if (!strWalletSQLiteVerify.empty()) + { + int nRet = CmdWalletSQLiteVerify(strWalletSQLiteVerify); + DBFlush(true); + return nRet; + } + // Berkeley DB reports failure by throwing, and CDB's constructor lets it // through. Nothing on this path caught anything, so an unreadable // blkindex.dat or wallet.dat -- one written by another platform's Berkeley diff --git a/src/selftest.cpp b/src/selftest.cpp index 408dd41..4ba90c3 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1134,6 +1134,8 @@ static int RunWalletSQLiteSelfTest() return nFail == 0 ? 0 : 1; } +namespace { + class CWalletRecordMapVisitor : public CWalletRecordVisitor { public: @@ -1158,6 +1160,8 @@ public: } }; +} // namespace + static int RunWalletSQLiteMigrationSelfTest() { fflush(stdout); @@ -1215,6 +1219,17 @@ static int RunWalletSQLiteMigrationSelfTest() FileContainsText(strExportOut, "SQLite wallet export written"), "wallet.dat can be exported to SQLite") ? 0 : 1; + string strVerifyOut = tmp + "/sqlite-verify.txt"; + vector vVerifyArgs; + vVerifyArgs.push_back("-datadir=" + strWalletDir); + vVerifyArgs.push_back("-nomanagedtor"); + vVerifyArgs.push_back("-nogui"); + vVerifyArgs.push_back("-walletsqliteverify=" + strSQLite); + int nVerifyRet = RunBitflashChild(strExe, vVerifyArgs, NULL, &strVerifyOut); + nFail += Check(nVerifyRet == 0 && + FileContainsText(strVerifyOut, "verification: ok"), + "SQLite wallet export verifies against wallet.dat") ? 0 : 1; + string strOverwriteOut = tmp + "/sqlite-export-overwrite.txt"; int nOverwriteRet = RunBitflashChild(strExe, vExportArgs, NULL, &strOverwriteOut); @@ -1265,6 +1280,32 @@ static int RunWalletSQLiteMigrationSelfTest() nFail += Check(db.ScanRecords(sqliteVisitor, strError) && mapSQLite == mapBDB, "SQLite wallet scanner streams every exported record byte-for-byte") ? 0 : 1; + + db.Close(); + CWalletDBSQLite dbCorrupt; + vector vchBadValue; + vchBadValue.push_back(0xba); + vchBadValue.push_back(0xad); + nFail += Check(!mapBDB.empty() && + dbCorrupt.Open(strSQLite, strError) && + dbCorrupt.BeginTransaction(strError) && + dbCorrupt.WriteRecord(mapBDB.begin()->first, + vchBadValue, + strError) && + dbCorrupt.CommitTransaction(strError) && + dbCorrupt.Checkpoint(strError), + "SQLite export can be deliberately corrupted for verifier test") ? 0 : 1; + dbCorrupt.Close(); + + string strVerifyBadOut = tmp + "/sqlite-verify-bad.txt"; + int nVerifyBadRet = + RunBitflashChild(strExe, vVerifyArgs, NULL, &strVerifyBadOut); + nFail += Check(nVerifyBadRet == 2 && + FileContainsText(strVerifyBadOut, + "record value mismatches: 1") && + FileContainsText(strVerifyBadOut, + "verification: failed"), + "SQLite wallet verifier detects a mismatched record value") ? 0 : 1; } catch (const std::exception& e) { diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index 20e7e33..36f6783 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -796,6 +796,8 @@ static vector DataStreamBytesLocal(const CDataStream& ss) return vector(ss.begin(), ss.end()); } +namespace { + class CWalletSQLiteExportVisitor : public CWalletRecordVisitor { public: @@ -818,6 +820,32 @@ public: } }; +class CWalletRecordMapVisitor : public CWalletRecordVisitor +{ +public: + std::map, vector >& mapRecords; + + explicit CWalletRecordMapVisitor( + std::map, vector >& mapRecordsIn) + : mapRecords(mapRecordsIn) { } + + bool VisitWalletRecord(const CDataStream& ssKey, + const CDataStream& ssValue, + string& strErrorRet) + { + vector vchKey = DataStreamBytesLocal(ssKey); + if (mapRecords.count(vchKey)) + { + strErrorRet = "duplicate serialized wallet key"; + return false; + } + mapRecords[vchKey] = DataStreamBytesLocal(ssValue); + return true; + } +}; + +} // namespace + int CmdWalletSQLiteExport(const std::string& strDest) { AttachTerminal(); @@ -887,6 +915,85 @@ int CmdWalletSQLiteExport(const std::string& strDest) return 0; } +int CmdWalletSQLiteVerify(const std::string& strPath) +{ + AttachTerminal(); + + if (strPath.empty()) + { + fprintf(stderr, "Missing SQLite wallet export path.\n"); + return 1; + } + if (!FileExists(strPath.c_str())) + { + fprintf(stderr, "SQLite wallet export does not exist: %s\n", + strPath.c_str()); + return 1; + } + + std::map, vector > mapBDB; + std::map, vector > mapSQLite; + string strError; + + CWalletRecordMapVisitor bdbVisitor(mapBDB); + if (!ScanWalletRecords(bdbVisitor, strError)) + { + fprintf(stderr, "Cannot scan wallet.dat records: %s\n", strError.c_str()); + return 1; + } + + CWalletDBSQLite db; + if (!db.Open(strPath, strError)) + { + fprintf(stderr, "Cannot open SQLite wallet export: %s\n", strError.c_str()); + return 1; + } + CWalletRecordMapVisitor sqliteVisitor(mapSQLite); + if (!db.ScanRecords(sqliteVisitor, strError)) + { + fprintf(stderr, "Cannot scan SQLite wallet export: %s\n", strError.c_str()); + return 1; + } + + int nMissing = 0; + int nMismatched = 0; + for (std::map, vector >::const_iterator it = + mapBDB.begin(); it != mapBDB.end(); ++it) + { + std::map, vector >::const_iterator sit = + mapSQLite.find(it->first); + if (sit == mapSQLite.end()) + nMissing++; + else if (sit->second != it->second) + nMismatched++; + } + + int nExtra = 0; + for (std::map, vector >::const_iterator it = + mapSQLite.begin(); it != mapSQLite.end(); ++it) + { + if (!mapBDB.count(it->first)) + nExtra++; + } + + printf("SQLite wallet export verification\n"); + printf(" wallet.dat records: %u\n", (unsigned int)mapBDB.size()); + printf(" SQLite records: %u\n", (unsigned int)mapSQLite.size()); + printf(" missing records: %d\n", nMissing); + printf(" extra records: %d\n", nExtra); + printf(" record value mismatches: %d\n", nMismatched); + if (nMissing == 0 && nExtra == 0 && nMismatched == 0) + { + printf(" verification: ok\n"); + fflush(stdout); + return 0; + } + + printf(" verification: failed\n"); + fflush(stdout); + return 2; +} + int CmdEncryptWallet(const std::string& strPassphrase) { AttachTerminal(); diff --git a/src/walletcmd.h b/src/walletcmd.h index b1a29d2..784c74c 100644 --- a/src/walletcmd.h +++ b/src/walletcmd.h @@ -75,6 +75,10 @@ int CmdWalletStorageCheck(); // not change which backend the runtime wallet opens. int CmdWalletSQLiteExport(const std::string& strDest); +// Diagnostic/migration staging: compare a SQLite wallet export with the current +// wallet.dat raw records. Prints counts only, never record keys or values. +int CmdWalletSQLiteVerify(const std::string& strPath); + // Rewrite wallet.dat so private keys and the HD seed are encrypted with the // given passphrase. Returns 0 on success and exits without starting the node. int CmdEncryptWallet(const std::string& strPassphrase); -- cgit v1.2.3 From df129cc672b9b894ff2558d3e9361ddab082433d Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 14 Aug 2026 17:02:35 -0300 Subject: wallet: restore BDB wallets from SQLite exports --- docs/storage.md | 3 ++ src/main_gui.cpp | 11 +++++ src/selftest.cpp | 35 +++++++++++++++ src/walletcmd.cpp | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/walletcmd.h | 4 ++ 5 files changed, 179 insertions(+) (limited to 'src/selftest.cpp') diff --git a/docs/storage.md b/docs/storage.md index 0a441bc..4947575 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -113,6 +113,9 @@ through the same visitor shape, so tests can compare BDB and SQLite record streams without introducing a SQLite runtime wallet loader yet. `-walletsqliteverify=FILE` compares the current `wallet.dat` stream with a SQLite export and prints only record counts and mismatch counts. +`-walletsqliterestore=FILE` rebuilds `wallet.dat` from a SQLite export only in +an empty data directory, then the verifier can prove the restored BDB file still +matches the export byte-for-byte. ## `blkindex.dat` diff --git a/src/main_gui.cpp b/src/main_gui.cpp index caacf13..cf96822 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -185,6 +185,8 @@ static void PrintUsage() printf(" experimental SQLite store, then exit)\n"); printf(" /walletsqliteverify=FILE (compare wallet.dat with a SQLite export,\n"); printf(" printing counts only, then exit)\n"); + printf(" /walletsqliterestore=FILE (rebuild wallet.dat from a SQLite export in\n"); + printf(" an empty data directory, then exit)\n"); printf(" /rescan (walk the chain for coins this wallet owns\n"); printf(" but never recorded, then exit)\n"); printf("\n"); @@ -551,6 +553,15 @@ int main(int argc, char* argv[]) return nRet; } + string strWalletSQLiteRestore = + argval2(argc, argv, "/walletsqliterestore", "-walletsqliterestore"); + if (!strWalletSQLiteRestore.empty()) + { + int nRet = CmdWalletSQLiteRestore(strWalletSQLiteRestore); + DBFlush(true); + return nRet; + } + // Berkeley DB reports failure by throwing, and CDB's constructor lets it // through. Nothing on this path caught anything, so an unreadable // blkindex.dat or wallet.dat -- one written by another platform's Berkeley diff --git a/src/selftest.cpp b/src/selftest.cpp index 4ba90c3..7d3a96e 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1230,6 +1230,41 @@ static int RunWalletSQLiteMigrationSelfTest() FileContainsText(strVerifyOut, "verification: ok"), "SQLite wallet export verifies against wallet.dat") ? 0 : 1; + string strRestoredDir = tmp + "/restored-wallet"; + nFail += Check(MakeDirLocal(strRestoredDir), + "restored wallet directory can be created") ? 0 : 1; + string strRestoreOut = tmp + "/sqlite-restore.txt"; + vector vRestoreArgs; + vRestoreArgs.push_back("-datadir=" + strRestoredDir); + vRestoreArgs.push_back("-nomanagedtor"); + vRestoreArgs.push_back("-nogui"); + vRestoreArgs.push_back("-walletsqliterestore=" + strSQLite); + int nRestoreRet = RunBitflashChild(strExe, vRestoreArgs, NULL, &strRestoreOut); + nFail += Check(nRestoreRet == 0 && + FileContainsText(strRestoreOut, "records restored:"), + "SQLite wallet export restores into a fresh wallet.dat") ? 0 : 1; + + string strVerifyRestoredOut = tmp + "/sqlite-verify-restored.txt"; + vector vVerifyRestoredArgs; + vVerifyRestoredArgs.push_back("-datadir=" + strRestoredDir); + vVerifyRestoredArgs.push_back("-nomanagedtor"); + vVerifyRestoredArgs.push_back("-nogui"); + vVerifyRestoredArgs.push_back("-walletsqliteverify=" + strSQLite); + int nVerifyRestoredRet = + RunBitflashChild(strExe, vVerifyRestoredArgs, NULL, &strVerifyRestoredOut); + nFail += Check(nVerifyRestoredRet == 0 && + FileContainsText(strVerifyRestoredOut, + "verification: ok"), + "restored wallet.dat verifies against the SQLite export") ? 0 : 1; + + string strRestoreOverwriteOut = tmp + "/sqlite-restore-overwrite.txt"; + int nRestoreOverwriteRet = + RunBitflashChild(strExe, vRestoreArgs, NULL, &strRestoreOverwriteOut); + nFail += Check(nRestoreOverwriteRet == 1 && + FileContainsText(strRestoreOverwriteOut, + "Refusing to overwrite existing wallet.dat"), + "SQLite wallet restore refuses to overwrite wallet.dat") ? 0 : 1; + string strOverwriteOut = tmp + "/sqlite-export-overwrite.txt"; int nOverwriteRet = RunBitflashChild(strExe, vExportArgs, NULL, &strOverwriteOut); diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index 36f6783..d1bad98 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -844,6 +844,52 @@ public: } }; +class CWalletRawRestoreDB : public CWalletDB +{ +public: + CWalletRawRestoreDB(const char* pszMode="cr+", bool fTxn=true) + : CWalletDB(pszMode, fTxn) { } + + bool WriteRaw(CDataStream ssKey, CDataStream ssValue) + { + if (!pdb || ssKey.empty()) + return false; + + Dbt datKey((void*)&ssKey[0], ssKey.size()); + Dbt datValue(ssValue.empty() ? NULL : (void*)&ssValue[0], + ssValue.size()); + int ret = pdb->put(GetTxn(), &datKey, &datValue, 0); + + memset(datKey.get_data(), 0, datKey.get_size()); + if (datValue.get_data()) + memset(datValue.get_data(), 0, datValue.get_size()); + return ret == 0; + } +}; + +class CWalletSQLiteRestoreVisitor : public CWalletRecordVisitor +{ +public: + CWalletRawRestoreDB& db; + int& nCopied; + + CWalletSQLiteRestoreVisitor(CWalletRawRestoreDB& dbIn, int& nCopiedIn) + : db(dbIn), nCopied(nCopiedIn) { } + + bool VisitWalletRecord(const CDataStream& ssKey, + const CDataStream& ssValue, + string& strErrorRet) + { + if (!db.WriteRaw(ssKey, ssValue)) + { + strErrorRet = "could not write raw wallet record"; + return false; + } + nCopied++; + return true; + } +}; + } // namespace int CmdWalletSQLiteExport(const std::string& strDest) @@ -994,6 +1040,86 @@ int CmdWalletSQLiteVerify(const std::string& strPath) return 2; } +int CmdWalletSQLiteRestore(const std::string& strPath) +{ + AttachTerminal(); + + if (strPath.empty()) + { + fprintf(stderr, "Missing SQLite wallet export path.\n"); + return 1; + } + if (!FileExists(strPath.c_str())) + { + fprintf(stderr, "SQLite wallet export does not exist: %s\n", + strPath.c_str()); + return 1; + } + + string strWalletPath = GetAppDir() + "/wallet.dat"; + if (FileExists(strWalletPath.c_str())) + { + fprintf(stderr, "Refusing to overwrite existing wallet.dat: %s\n", + strWalletPath.c_str()); + return 1; + } + + string strError; + CWalletDBSQLite sqlite; + if (!sqlite.Open(strPath, strError)) + { + fprintf(stderr, "Cannot open SQLite wallet export: %s\n", strError.c_str()); + return 1; + } + + int nSQLiteRecords = 0; + if (!sqlite.CountRecords(nSQLiteRecords, strError)) + { + fprintf(stderr, "Cannot count SQLite wallet export records: %s\n", + strError.c_str()); + return 1; + } + if (nSQLiteRecords <= 0) + { + fprintf(stderr, "SQLite wallet export contains no records.\n"); + return 2; + } + + CWalletRawRestoreDB bdb("cr+", true); + if (!bdb.TxnBegin()) + { + fprintf(stderr, "Cannot begin Berkeley DB restore transaction.\n"); + return 1; + } + + int nCopied = 0; + CWalletSQLiteRestoreVisitor visitor(bdb, nCopied); + if (!sqlite.ScanRecords(visitor, strError)) + { + bdb.TxnAbort(); + fprintf(stderr, "Cannot restore SQLite wallet export: %s\n", + strError.c_str()); + return 1; + } + if (nCopied != nSQLiteRecords) + { + bdb.TxnAbort(); + fprintf(stderr, "SQLite wallet restore copied %d records, expected %d.\n", + nCopied, nSQLiteRecords); + return 2; + } + if (!bdb.TxnCommit()) + { + fprintf(stderr, "Cannot commit Berkeley DB restore transaction.\n"); + return 1; + } + + printf("SQLite wallet export restored to wallet.dat\n"); + printf(" records restored: %d\n", nCopied); + fflush(stdout); + return 0; +} + int CmdEncryptWallet(const std::string& strPassphrase) { AttachTerminal(); diff --git a/src/walletcmd.h b/src/walletcmd.h index 784c74c..50dc69e 100644 --- a/src/walletcmd.h +++ b/src/walletcmd.h @@ -79,6 +79,10 @@ int CmdWalletSQLiteExport(const std::string& strDest); // wallet.dat raw records. Prints counts only, never record keys or values. int CmdWalletSQLiteVerify(const std::string& strPath); +// Diagnostic/migration staging: rebuild wallet.dat from a SQLite export in an +// empty data directory. Refuses to overwrite an existing wallet.dat. +int CmdWalletSQLiteRestore(const std::string& strPath); + // Rewrite wallet.dat so private keys and the HD seed are encrypted with the // given passphrase. Returns 0 on success and exits without starting the node. int CmdEncryptWallet(const std::string& strPassphrase); -- cgit v1.2.3 From f7f9885e89e2c2527933b99ec713592ebf930483 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 14 Aug 2026 17:23:21 -0300 Subject: wallet: clean up failed SQLite migrations --- docs/storage.md | 3 ++- src/selftest.cpp | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/walletcmd.cpp | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) (limited to 'src/selftest.cpp') diff --git a/docs/storage.md b/docs/storage.md index 4947575..aca3f6e 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -115,7 +115,8 @@ streams without introducing a SQLite runtime wallet loader yet. SQLite export and prints only record counts and mismatch counts. `-walletsqliterestore=FILE` rebuilds `wallet.dat` from a SQLite export only in an empty data directory, then the verifier can prove the restored BDB file still -matches the export byte-for-byte. +matches the export byte-for-byte. Failed exports remove the incomplete SQLite +file and failed restores remove the incomplete `wallet.dat`. ## `blkindex.dat` diff --git a/src/selftest.cpp b/src/selftest.cpp index 7d3a96e..343d9c5 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1162,6 +1162,39 @@ public: } // namespace +static bool CreateSQLiteWalletWithEmptyKey(const string& strPath) +{ + sqlite3* pdb = NULL; + int ret = sqlite3_open_v2(strPath.c_str(), &pdb, + SQLITE_OPEN_READWRITE | + SQLITE_OPEN_CREATE | + SQLITE_OPEN_FULLMUTEX, + NULL); + if (ret != SQLITE_OK || !pdb) + { + if (pdb) + sqlite3_close(pdb); + return false; + } + + char* pszErr = NULL; + bool fOk = + sqlite3_exec(pdb, + "CREATE TABLE wallet_records (" + "key BLOB PRIMARY KEY NOT NULL," + "value BLOB NOT NULL" + ");", + NULL, NULL, &pszErr) == SQLITE_OK && + sqlite3_exec(pdb, + "INSERT INTO wallet_records(key, value) " + "VALUES(x'', x'01');", + NULL, NULL, &pszErr) == SQLITE_OK; + if (pszErr) + sqlite3_free(pszErr); + sqlite3_close(pdb); + return fOk; +} + static int RunWalletSQLiteMigrationSelfTest() { fflush(stdout); @@ -1265,6 +1298,26 @@ static int RunWalletSQLiteMigrationSelfTest() "Refusing to overwrite existing wallet.dat"), "SQLite wallet restore refuses to overwrite wallet.dat") ? 0 : 1; + string strBadSQLite = tmp + "/bad-empty-key.sqlite"; + nFail += Check(CreateSQLiteWalletWithEmptyKey(strBadSQLite), + "bad SQLite export with an empty key can be created") ? 0 : 1; + string strBadRestoreDir = tmp + "/bad-restore-wallet"; + nFail += Check(MakeDirLocal(strBadRestoreDir), + "bad restore wallet directory can be created") ? 0 : 1; + string strBadRestoreOut = tmp + "/sqlite-restore-bad.txt"; + vector vBadRestoreArgs; + vBadRestoreArgs.push_back("-datadir=" + strBadRestoreDir); + vBadRestoreArgs.push_back("-nomanagedtor"); + vBadRestoreArgs.push_back("-nogui"); + vBadRestoreArgs.push_back("-walletsqliterestore=" + strBadSQLite); + int nBadRestoreRet = + RunBitflashChild(strExe, vBadRestoreArgs, NULL, &strBadRestoreOut); + nFail += Check(nBadRestoreRet == 1 && + FileContainsText(strBadRestoreOut, + "Cannot restore SQLite wallet export") && + !FileExists((strBadRestoreDir + "/wallet.dat").c_str()), + "failed SQLite wallet restore removes incomplete wallet.dat") ? 0 : 1; + string strOverwriteOut = tmp + "/sqlite-export-overwrite.txt"; int nOverwriteRet = RunBitflashChild(strExe, vExportArgs, NULL, &strOverwriteOut); diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index d1bad98..9e4a981 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -798,6 +798,19 @@ static vector DataStreamBytesLocal(const CDataStream& ss) namespace { +static void RemoveSQLiteExportFiles(const string& strPath) +{ + remove(strPath.c_str()); + remove((strPath + "-wal").c_str()); + remove((strPath + "-shm").c_str()); +} + +static void RemoveFailedRestoreWallet(const string& strWalletPath) +{ + DBFlush(true); + remove(strWalletPath.c_str()); +} + class CWalletSQLiteExportVisitor : public CWalletRecordVisitor { public: @@ -912,11 +925,14 @@ int CmdWalletSQLiteExport(const std::string& strDest) CWalletDBSQLite db; if (!db.Open(strDest, strError)) { + RemoveSQLiteExportFiles(strDest); fprintf(stderr, "Cannot open SQLite wallet export: %s\n", strError.c_str()); return 1; } if (!db.BeginTransaction(strError)) { + db.Close(); + RemoveSQLiteExportFiles(strDest); fprintf(stderr, "Cannot begin SQLite wallet export: %s\n", strError.c_str()); return 1; } @@ -927,16 +943,22 @@ int CmdWalletSQLiteExport(const std::string& strDest) { string strRollbackError; db.RollbackTransaction(strRollbackError); + db.Close(); + RemoveSQLiteExportFiles(strDest); fprintf(stderr, "Cannot export wallet.dat to SQLite: %s\n", strError.c_str()); return 1; } if (!db.CommitTransaction(strError)) { + db.Close(); + RemoveSQLiteExportFiles(strDest); fprintf(stderr, "Cannot commit SQLite wallet export: %s\n", strError.c_str()); return 1; } if (!db.Checkpoint(strError)) { + db.Close(); + RemoveSQLiteExportFiles(strDest); fprintf(stderr, "Cannot checkpoint SQLite wallet export: %s\n", strError.c_str()); return 1; } @@ -944,12 +966,16 @@ int CmdWalletSQLiteExport(const std::string& strDest) int nCount = 0; if (!db.CountRecords(nCount, strError)) { + db.Close(); + RemoveSQLiteExportFiles(strDest); fprintf(stderr, "Cannot verify SQLite wallet export count: %s\n", strError.c_str()); return 1; } if (nCount != nCopied) { + db.Close(); + RemoveSQLiteExportFiles(strDest); fprintf(stderr, "SQLite wallet export count mismatch: copied %d, stored %d\n", nCopied, nCount); return 1; @@ -1097,6 +1123,8 @@ int CmdWalletSQLiteRestore(const std::string& strPath) if (!sqlite.ScanRecords(visitor, strError)) { bdb.TxnAbort(); + bdb.Close(); + RemoveFailedRestoreWallet(strWalletPath); fprintf(stderr, "Cannot restore SQLite wallet export: %s\n", strError.c_str()); return 1; @@ -1104,12 +1132,16 @@ int CmdWalletSQLiteRestore(const std::string& strPath) if (nCopied != nSQLiteRecords) { bdb.TxnAbort(); + bdb.Close(); + RemoveFailedRestoreWallet(strWalletPath); fprintf(stderr, "SQLite wallet restore copied %d records, expected %d.\n", nCopied, nSQLiteRecords); return 2; } if (!bdb.TxnCommit()) { + bdb.Close(); + RemoveFailedRestoreWallet(strWalletPath); fprintf(stderr, "Cannot commit Berkeley DB restore transaction.\n"); return 1; } -- cgit v1.2.3 From 3e778dd6cc9045862e99503b5b9af80ec682826f Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 14 Aug 2026 17:44:09 -0300 Subject: wallet: check SQLite exports against loader types --- docs/storage.md | 3 + src/main_gui.cpp | 11 +++ src/selftest.cpp | 54 ++++++++++++++ src/walletcmd.cpp | 209 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/walletcmd.h | 4 ++ 5 files changed, 281 insertions(+) (limited to 'src/selftest.cpp') diff --git a/docs/storage.md b/docs/storage.md index aca3f6e..07c7170 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -117,6 +117,9 @@ SQLite export and prints only record counts and mismatch counts. an empty data directory, then the verifier can prove the restored BDB file still matches the export byte-for-byte. Failed exports remove the incomplete SQLite file and failed restores remove the incomplete `wallet.dat`. +`-walletsqliteloadcheck=FILE` parses a SQLite export using the same record +types the wallet loader understands, so a migration can prove the export is not +just byte-preserving but loader-compatible before runtime activation. ## `blkindex.dat` diff --git a/src/main_gui.cpp b/src/main_gui.cpp index cf96822..c9aac7d 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -187,6 +187,8 @@ static void PrintUsage() printf(" printing counts only, then exit)\n"); printf(" /walletsqliterestore=FILE (rebuild wallet.dat from a SQLite export in\n"); printf(" an empty data directory, then exit)\n"); + printf(" /walletsqliteloadcheck=FILE\n"); + printf(" (parse a SQLite export like the wallet loader)\n"); printf(" /rescan (walk the chain for coins this wallet owns\n"); printf(" but never recorded, then exit)\n"); printf("\n"); @@ -562,6 +564,15 @@ int main(int argc, char* argv[]) return nRet; } + string strWalletSQLiteLoadCheck = + argval2(argc, argv, "/walletsqliteloadcheck", "-walletsqliteloadcheck"); + if (!strWalletSQLiteLoadCheck.empty()) + { + int nRet = CmdWalletSQLiteLoadCheck(strWalletSQLiteLoadCheck); + DBFlush(true); + return nRet; + } + // Berkeley DB reports failure by throwing, and CDB's constructor lets it // through. Nothing on this path caught anything, so an unreadable // blkindex.dat or wallet.dat -- one written by another platform's Berkeley diff --git a/src/selftest.cpp b/src/selftest.cpp index 343d9c5..feb4076 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1195,6 +1195,28 @@ static bool CreateSQLiteWalletWithEmptyKey(const string& strPath) return fOk; } +static bool CreateSQLiteWalletWithFutureMinVersion(const string& strPath) +{ + string strError; + CWalletDBSQLite db; + CDataStream ssKey(SER_DISK); + ssKey << string("walletminversion"); + CDataStream ssValue(SER_DISK); + ssValue << (WALLET_FORMAT_SUPPORTED + 1); + + if (!db.Open(strPath, strError)) + return false; + if (!db.BeginTransaction(strError)) + return false; + if (!db.WriteRecord(DataStreamBytes(ssKey), DataStreamBytes(ssValue), strError)) + return false; + if (!db.CommitTransaction(strError)) + return false; + if (!db.Checkpoint(strError)) + return false; + return true; +} + static int RunWalletSQLiteMigrationSelfTest() { fflush(stdout); @@ -1263,6 +1285,19 @@ static int RunWalletSQLiteMigrationSelfTest() FileContainsText(strVerifyOut, "verification: ok"), "SQLite wallet export verifies against wallet.dat") ? 0 : 1; + string strLoadCheckOut = tmp + "/sqlite-loadcheck.txt"; + vector vLoadCheckArgs; + vLoadCheckArgs.push_back("-datadir=" + strWalletDir); + vLoadCheckArgs.push_back("-nomanagedtor"); + vLoadCheckArgs.push_back("-nogui"); + vLoadCheckArgs.push_back("-walletsqliteloadcheck=" + strSQLite); + int nLoadCheckRet = + RunBitflashChild(strExe, vLoadCheckArgs, NULL, &strLoadCheckOut); + nFail += Check(nLoadCheckRet == 0 && + FileContainsText(strLoadCheckOut, + "load check: ok"), + "SQLite wallet export passes the loader compatibility check") ? 0 : 1; + string strRestoredDir = tmp + "/restored-wallet"; nFail += Check(MakeDirLocal(strRestoredDir), "restored wallet directory can be created") ? 0 : 1; @@ -1318,6 +1353,25 @@ static int RunWalletSQLiteMigrationSelfTest() !FileExists((strBadRestoreDir + "/wallet.dat").c_str()), "failed SQLite wallet restore removes incomplete wallet.dat") ? 0 : 1; + string strFutureSQLite = tmp + "/future-wallet.sqlite"; + nFail += Check(CreateSQLiteWalletWithFutureMinVersion(strFutureSQLite), + "SQLite export with a future wallet format can be created") ? 0 : 1; + string strFutureLoadCheckOut = tmp + "/sqlite-loadcheck-future.txt"; + vector vFutureLoadCheckArgs; + vFutureLoadCheckArgs.push_back("-datadir=" + strWalletDir); + vFutureLoadCheckArgs.push_back("-nomanagedtor"); + vFutureLoadCheckArgs.push_back("-nogui"); + vFutureLoadCheckArgs.push_back("-walletsqliteloadcheck=" + strFutureSQLite); + int nFutureLoadCheckRet = + RunBitflashChild(strExe, vFutureLoadCheckArgs, NULL, + &strFutureLoadCheckOut); + nFail += Check(nFutureLoadCheckRet == 2 && + FileContainsText(strFutureLoadCheckOut, + "unsupported future wallet format") && + FileContainsText(strFutureLoadCheckOut, + "load check: failed"), + "SQLite wallet load check rejects future wallet formats") ? 0 : 1; + string strOverwriteOut = tmp + "/sqlite-export-overwrite.txt"; int nOverwriteRet = RunBitflashChild(strExe, vExportArgs, NULL, &strOverwriteOut); diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index 9e4a981..4dda8dd 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -903,6 +903,171 @@ public: } }; +class CWalletSQLiteLoadCheckVisitor : public CWalletRecordVisitor +{ +public: + int nRecords; + int nUnknown; + string strFailure; + + CWalletSQLiteLoadCheckVisitor() : nRecords(0), nUnknown(0) { } + + bool Fail(const string& strMessage, string& strErrorRet) + { + strFailure = strMessage; + strErrorRet = strMessage; + return false; + } + + bool VisitWalletRecord(const CDataStream& ssKeyIn, + const CDataStream& ssValueIn, + string& strErrorRet) + { + nRecords++; + try + { + CDataStream ssKey = ssKeyIn; + CDataStream ssValue = ssValueIn; + string strType; + ssKey >> strType; + if (ssKey.fail()) + return Fail("malformed wallet record key", strErrorRet); + + if (strType == "name") + { + string strAddress; + string strName; + ssKey >> strAddress; + ssValue >> strName; + } + else if (strType == "tx") + { + uint256 hash; + CWalletTx wtx; + ssKey >> hash; + ssValue >> wtx; + } + else if (strType == "key") + { + vector vchPubKey; + CPrivKey vchPrivKey; + ssKey >> vchPubKey; + ssValue >> vchPrivKey; + } + else if (strType == "mkey") + { + unsigned int nID = 0; + CWalletMasterKey kMasterKey; + ssKey >> nID; + ssValue >> kMasterKey; + } + else if (strType == "ckey") + { + vector vchPubKey; + vector vchCryptedSecret; + ssKey >> vchPubKey; + ssValue >> vchCryptedSecret; + } + else if (strType == "defaultkey" || + strType == "hdmaster" || + strType == "hdchaincode" || + strType == "cryptedhdmaster" || + strType == "cryptedhdchaincode") + { + vector vch; + ssValue >> vch; + } + else if (strType == "hdnext" || + strType == "hdreceivenext" || + strType == "hdchangenext" || + strType == "hdcointype") + { + unsigned int nValue = 0; + ssValue >> nValue; + } + else if (strType == "hdschema") + { + int nSchema = 0; + ssValue >> nSchema; + if (!ssValue.fail() && + nSchema != HD_SCHEMA_NONE && + nSchema != HD_SCHEMA_LEGACY && + nSchema != HD_SCHEMA_BIP44) + return Fail("unsupported deterministic wallet schema", + strErrorRet); + } + else if (strType == "walletminversion") + { + int nMinVersion = 0; + ssValue >> nMinVersion; + if (!ssValue.fail() && nMinVersion > WALLET_FORMAT_SUPPORTED) + return Fail("unsupported future wallet format", strErrorRet); + } + else if (strType == "pool") + { + int64 nIndex = 0; + vector vchPubKey; + ssKey >> nIndex; + ssValue >> vchPubKey; + } + else if (strType == "setting") + { + string strKey; + ssKey >> strKey; + if (strKey == "nTransactionFee") + { + int64 nValue = 0; + ssValue >> nValue; + } + else if (strKey == "addrIncoming") + { + CAddress addr; + ssValue >> addr; + } + else if (strKey == "nMineMode") + { + int nValue = 0; + ssValue >> nValue; + } + else if (strKey == "fGenerateBitcoins") + { + int fValue = 0; + ssValue >> fValue; + } + else if (strKey == "strParticipantPool") + { + string strValue; + ssValue >> strValue; + } + } + else if (strType == "version") + { + int nVersion = 0; + ssValue >> nVersion; + } + else + { + nUnknown++; + } + + if (ssKey.fail()) + return Fail("malformed wallet record key", strErrorRet); + if (ssValue.fail()) + return Fail("malformed wallet record value", strErrorRet); + } + catch (const std::exception& e) + { + return Fail(strprintf("wallet record parse exception: %s", e.what()), + strErrorRet); + } + catch (...) + { + return Fail("wallet record parse exception", strErrorRet); + } + return true; + } +}; + } // namespace int CmdWalletSQLiteExport(const std::string& strDest) @@ -1152,6 +1317,50 @@ int CmdWalletSQLiteRestore(const std::string& strPath) return 0; } +int CmdWalletSQLiteLoadCheck(const std::string& strPath) +{ + AttachTerminal(); + + if (strPath.empty()) + { + fprintf(stderr, "Missing SQLite wallet export path.\n"); + return 1; + } + if (!FileExists(strPath.c_str())) + { + fprintf(stderr, "SQLite wallet export does not exist: %s\n", + strPath.c_str()); + return 1; + } + + string strError; + CWalletDBSQLite sqlite; + if (!sqlite.Open(strPath, strError)) + { + fprintf(stderr, "Cannot open SQLite wallet export: %s\n", strError.c_str()); + return 1; + } + + CWalletSQLiteLoadCheckVisitor visitor; + if (!sqlite.ScanRecords(visitor, strError)) + { + printf("SQLite wallet load check\n"); + printf(" records checked: %d\n", visitor.nRecords); + printf(" unknown records: %d\n", visitor.nUnknown); + printf(" load check: failed\n"); + printf(" failure: %s\n", strError.c_str()); + fflush(stdout); + return 2; + } + + printf("SQLite wallet load check\n"); + printf(" records checked: %d\n", visitor.nRecords); + printf(" unknown records: %d\n", visitor.nUnknown); + printf(" load check: ok\n"); + fflush(stdout); + return 0; +} + int CmdEncryptWallet(const std::string& strPassphrase) { AttachTerminal(); diff --git a/src/walletcmd.h b/src/walletcmd.h index 50dc69e..d993856 100644 --- a/src/walletcmd.h +++ b/src/walletcmd.h @@ -83,6 +83,10 @@ int CmdWalletSQLiteVerify(const std::string& strPath); // empty data directory. Refuses to overwrite an existing wallet.dat. int CmdWalletSQLiteRestore(const std::string& strPath); +// Diagnostic/migration staging: parse a SQLite wallet export as the runtime +// wallet loader would. Prints counts only, never record keys or values. +int CmdWalletSQLiteLoadCheck(const std::string& strPath); + // Rewrite wallet.dat so private keys and the HD seed are encrypted with the // given passphrase. Returns 0 on success and exits without starting the node. int CmdEncryptWallet(const std::string& strPassphrase); -- cgit v1.2.3 From f9ee957bd6d3f28dfee166a82f389aad666a0119 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 14 Aug 2026 18:17:23 -0300 Subject: wallet: stage SQLite runtime loading --- docs/storage.md | 4 + src/db.cpp | 361 +++++++++++++++++++++++++++++++++++++++++++++++--- src/db.h | 1 + src/main_gui.cpp | 22 +++ src/selftest.cpp | 29 ++++ src/wallet_sqlite.cpp | 19 +++ src/wallet_sqlite.h | 1 + 7 files changed, 420 insertions(+), 17 deletions(-) (limited to 'src/selftest.cpp') diff --git a/docs/storage.md b/docs/storage.md index 07c7170..08c4a01 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -120,6 +120,10 @@ file and failed restores remove the incomplete `wallet.dat`. `-walletsqliteloadcheck=FILE` parses a SQLite export using the same record types the wallet loader understands, so a migration can prove the export is not just byte-preserving but loader-compatible before runtime activation. +`-walletsqlite=FILE` is the first runtime staging flag: it loads a SQLite export +through the startup wallet path, initializes the in-memory wallet state, reports +success, and exits before network, mining, GUI, or wallet mutation begins. It is +deliberately read-only until the write path is routed to SQLite as well. ## `blkindex.dat` diff --git a/src/db.cpp b/src/db.cpp index 8b82fe5..581e5a7 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -3,6 +3,7 @@ // file license.txt or http://www.opensource.org/licenses/mit-license.php. #include "headers.h" +#include "wallet_sqlite.h" @@ -650,6 +651,289 @@ bool CReviewDB::WriteReviews(uint256 hash, const vector& vReviews) // CWalletDB // +class CSQLiteWalletRuntimeLoadVisitor : public CWalletRecordVisitor +{ +public: + bool fHaveStoredMineMode; + bool fHaveStoredHDCoinType; + string strLastType; + + CSQLiteWalletRuntimeLoadVisitor() : + fHaveStoredMineMode(false), + fHaveStoredHDCoinType(false) + { + } + + bool VisitWalletRecord(const CDataStream& ssKeyIn, + const CDataStream& ssValueIn, + string& strErrorRet) + { + CDataStream ssKey = ssKeyIn; + CDataStream ssValue = ssValueIn; + string strType; + ssKey >> strType; + strLastType = strType; + + if (strType == "name") + { + string strAddress; + ssKey >> strAddress; + ssValue >> mapAddressBook[strAddress]; + } + else if (strType == "tx") + { + uint256 hash; + ssKey >> hash; + CWalletTx& wtx = mapWallet[hash]; + ssValue >> wtx; + + if (wtx.GetHash() != hash) + printf("Error in SQLite wallet export, hash mismatch\n"); + } + else if (strType == "key") + { + vector vchPubKey; + ssKey >> vchPubKey; + CPrivKey vchPrivKey; + ssValue >> vchPrivKey; + + mapKeys[vchPubKey] = vchPrivKey; + mapPubKeys[Hash160(vchPubKey)] = vchPubKey; + } + else if (strType == "mkey") + { + unsigned int nID = 0; + ssKey >> nID; + CWalletMasterKey kMasterKey; + ssValue >> kMasterKey; + mapMasterKeys[nID] = kMasterKey; + nWalletMasterKeyMaxID = max(nWalletMasterKeyMaxID, nID); + fWalletEncrypted = true; + fWalletLocked = true; + } + else if (strType == "ckey") + { + vector vchPubKey; + ssKey >> vchPubKey; + vector vchCryptedSecret; + ssValue >> vchCryptedSecret; + if (!AddCryptedKey(vchPubKey, vchCryptedSecret)) + { + strErrorRet = "SQLite wallet export has an unreadable encrypted key record"; + return false; + } + } + else if (strType == "defaultkey") + { + ssValue >> vchDefaultKey; + } + else if (strType == "hdmaster") + { + ssValue >> vchHDMaster; + } + else if (strType == "hdchaincode") + { + ssValue >> vchHDChainCode; + } + else if (strType == "hdnext") + { + ssValue >> nHDNext; + } + else if (strType == "hdschema") + { + ssValue >> nHDKeySchema; + } + else if (strType == "hdcointype") + { + ssValue >> nHDCoinType; + fHaveStoredHDCoinType = true; + } + else if (strType == "hdreceivenext") + { + ssValue >> nHDReceiveNext; + } + else if (strType == "hdchangenext") + { + ssValue >> nHDChangeNext; + } + else if (strType == "cryptedhdmaster") + { + ssValue >> vchCryptedHDMaster; + fWalletEncrypted = true; + fWalletLocked = true; + } + else if (strType == "cryptedhdchaincode") + { + ssValue >> vchCryptedHDChainCode; + fWalletEncrypted = true; + fWalletLocked = true; + } + else if (strType == "walletminversion") + { + int nMinVersion = 0; + ssValue >> nMinVersion; + if (nMinVersion > WALLET_FORMAT_SUPPORTED) + { + strWalletLoadError = strprintf( + "This SQLite wallet export was written by a newer version of " + "Bitflash (wallet format %d; this build understands %d). " + "Upgrade Bitflash before opening it.", + nMinVersion, WALLET_FORMAT_SUPPORTED); + strErrorRet = strWalletLoadError; + return false; + } + } + else if (strType == "pool") + { + int64 nIndex; + ssKey >> nIndex; + vector vchPubKey; + ssValue >> vchPubKey; + mapKeyPool[nIndex] = vchPubKey; + } + else if (strType == "setting") + { + string strKey; + ssKey >> strKey; + if (strKey == "nTransactionFee") ssValue >> nTransactionFee; + if (strKey == "addrIncoming") ssValue >> addrIncoming; + + if (!fMineModeFromCommandLine) + { + if (strKey == "nMineMode") { ssValue >> nMineMode; fHaveStoredMineMode = true; } + if (strKey == "fGenerateBitcoins") ssValue >> fGenerateBitcoins; + if (strKey == "strParticipantPool") ssValue >> strParticipantPool; + } + } + + if (ssKey.fail()) + { + strErrorRet = strprintf("malformed SQLite wallet record key while handling '%s'", + strLastType.c_str()); + return false; + } + if (ssValue.fail()) + { + strErrorRet = strprintf("malformed SQLite wallet record value while handling '%s'", + strLastType.c_str()); + return false; + } + return true; + } + + vector vchDefaultKey; +}; + +static bool FinishLoadedWallet(const vector& vchDefaultKey, + bool fMayCreateDefaultKey) +{ + if (!vchDefaultKey.empty() && mapPubKeys.count(Hash160(vchDefaultKey))) + { + keyUser.SetPubKey(vchDefaultKey); + if (mapKeys.count(vchDefaultKey)) + keyUser.SetPrivKey(mapKeys[vchDefaultKey]); + } + else if (IsWalletEncrypted()) + { + printf("LoadWallet: encrypted wallet has no usable default public key. " + "The file was left untouched.\n"); + return false; + } + else + { + if (!fMayCreateDefaultKey) + { + strWalletLoadError = + "SQLite wallet export has no usable default public key"; + printf("LoadWallet: %s\n", strWalletLoadError.c_str()); + return false; + } + + keyUser.MakeNewKey(); + if (!AddKey(keyUser)) + return false; + if (!SetAddressBookName(PubKeyToAddress(keyUser.GetPubKey()), "Your Address")) + return false; + CWalletDB().WriteDefaultKey(keyUser.GetPubKey()); + } + + return true; +} + +static bool ReconcileLoadedWalletMetadata(bool fHaveStoredMineMode, + bool fHaveStoredHDCoinType) +{ + // An encrypted wallet has a seed; it just has not been decrypted yet. + // + // The reset in the else branch was written when "no seed in memory after + // loading" could only mean "this wallet has no seed". With encryption that + // is no longer true, and taking the else branch here wiped the schema and + // the derivation counters that had just been read correctly out of the + // file. Unlocking later restores the seed but nothing restores those, so a + // BIP44 wallet came back deriving legacy addresses -- different addresses + // from the ones its own recovery phrase produces, while the audit reported + // the phrase as covering nothing. + if (HaveHDSeed() || !vchCryptedHDMaster.empty()) + { + if (nHDKeySchema == HD_SCHEMA_NONE) + { + nHDKeySchema = HD_SCHEMA_LEGACY; + printf("LoadWallet: deterministic seed has no schema metadata; " + "treating it as legacy m/index'\n"); + } + else if (nHDKeySchema != HD_SCHEMA_LEGACY && + nHDKeySchema != HD_SCHEMA_BIP44) + { + printf("LoadWallet: deterministic wallet schema %d (%s) is not " + "supported by this build\n", + nHDKeySchema, HDKeySchemaName(nHDKeySchema).c_str()); + return false; + } + if (!fHaveStoredHDCoinType) + { + nHDCoinType = HD_BIP44_COIN_TYPE_BITFLASH; + printf("LoadWallet: deterministic seed has no BIP44 coin type metadata; " + "using Bitflash coin type %u\n", nHDCoinType); + } + } + else + { + nHDKeySchema = HD_SCHEMA_NONE; + nHDNext = 0; + nHDReceiveNext = 0; + nHDChangeNext = 0; + nHDCoinType = HD_BIP44_COIN_TYPE_BITFLASH; + } + + // fGenerateBitcoins and nMineMode only mean anything together, and a + // wallet.dat can easily hold one without the other: Bitcoin 0.1.0 already + // wrote fGenerateBitcoins when mining was toggled, years before this fork + // had a mode at all. Restoring that lone flag put a node into the one state + // the miner cannot act on -- generate on, mode relay -- so every miner + // thread started and returned at the relay guard, and the machine sat there + // looking like it was mining while hashing nothing. That happened to a + // 32-core node on the first 1.2.11 start. + // + // So the flag is only believed when the mode it belongs to was stored with + // it, and the pair is made coherent either way. + if (!fMineModeFromCommandLine) + { + if (!fHaveStoredMineMode) + { + if (fGenerateBitcoins) + printf("LoadWallet: ignoring a stored fGenerateBitcoins with no mining mode " + "beside it -- it predates this setting and cannot be read on its own\n"); + fGenerateBitcoins = 0; + } + else + { + fGenerateBitcoins = (nMineMode != MINE_RELAY) ? 1 : 0; + } + } + + return true; +} + bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) { // Whether wallet.dat actually carried a mining mode, as opposed to just the @@ -1005,31 +1289,74 @@ bool LoadWallet() if (!CWalletDB("cr").LoadWallet(vchDefaultKey)) return false; - if (!vchDefaultKey.empty() && mapPubKeys.count(Hash160(vchDefaultKey))) + return FinishLoadedWallet(vchDefaultKey, true); +} + +bool LoadWalletFromSQLite(const string& strPath) +{ + strWalletLoadError.clear(); + + if (!FileExists(strPath.c_str())) { - // Set keyUser - keyUser.SetPubKey(vchDefaultKey); - if (mapKeys.count(vchDefaultKey)) - keyUser.SetPrivKey(mapKeys[vchDefaultKey]); + strWalletLoadError = strprintf("SQLite wallet export does not exist: %s", + strPath.c_str()); + printf("LoadWallet: %s\n", strWalletLoadError.c_str()); + return false; } - else if (IsWalletEncrypted()) + + CWalletDBSQLite sqlite; + string strError; + if (!sqlite.OpenReadOnly(strPath, strError)) { - printf("LoadWallet: encrypted wallet has no usable default public key. " - "The file was left untouched.\n"); + strWalletLoadError = strError; + printf("LoadWallet: cannot open SQLite wallet export: %s\n", + strError.c_str()); return false; } - else + + CSQLiteWalletRuntimeLoadVisitor visitor; + try { - // Create new keyUser and set as default key - keyUser.MakeNewKey(); - if (!AddKey(keyUser)) - return false; - if (!SetAddressBookName(PubKeyToAddress(keyUser.GetPubKey()), "Your Address")) - return false; - CWalletDB().WriteDefaultKey(keyUser.GetPubKey()); + CRITICAL_BLOCK(cs_mapKeys) + CRITICAL_BLOCK(cs_mapWallet) + { + if (!sqlite.ScanRecords(visitor, strError)) + { + if (strWalletLoadError.empty()) + strWalletLoadError = strError; + printf("LoadWallet: SQLite wallet export could not be read. " + "Failed while handling a '%s' record: %s\n", + visitor.strLastType.c_str(), strError.c_str()); + return false; + } + } + } + catch (const std::exception& e) + { + strWalletLoadError = strprintf( + "SQLite wallet export could not be read while handling a '%s' record: %s", + visitor.strLastType.c_str(), e.what()); + printf("LoadWallet: %s\n", strWalletLoadError.c_str()); + return false; + } + catch (...) + { + strWalletLoadError = strprintf( + "SQLite wallet export could not be read while handling a '%s' record", + visitor.strLastType.c_str()); + printf("LoadWallet: %s\n", strWalletLoadError.c_str()); + return false; } - return true; + if (!ReconcileLoadedWalletMetadata(visitor.fHaveStoredMineMode, + visitor.fHaveStoredHDCoinType)) + { + if (strWalletLoadError.empty()) + strWalletLoadError = "SQLite wallet metadata is not supported by this build"; + return false; + } + + return FinishLoadedWallet(visitor.vchDefaultKey, false); } diff --git a/src/db.h b/src/db.h index 53bda59..59ff24a 100644 --- a/src/db.h +++ b/src/db.h @@ -536,6 +536,7 @@ public: bool ScanWalletRecords(CWalletRecordVisitor& visitor, string& strErrorRet); bool LoadWallet(); +bool LoadWalletFromSQLite(const string& strPath); // Writes a wallet.dat that opens on its own, anywhere -- see the definition in // db.cpp for why a plain file copy does not. Still a point-in-time snapshot: diff --git a/src/main_gui.cpp b/src/main_gui.cpp index c9aac7d..ee875c7 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -189,6 +189,7 @@ static void PrintUsage() printf(" an empty data directory, then exit)\n"); printf(" /walletsqliteloadcheck=FILE\n"); printf(" (parse a SQLite export like the wallet loader)\n"); + printf(" /walletsqlite=FILE (stage-load a SQLite wallet export, then exit)\n"); printf(" /rescan (walk the chain for coins this wallet owns\n"); printf(" but never recorded, then exit)\n"); printf("\n"); @@ -617,6 +618,27 @@ int main(int argc, char* argv[]) printf("Loading wallet...\n"); try { + string strWalletSQLite = + argval2(argc, argv, "/walletsqlite", "-walletsqlite"); + if (!strWalletSQLite.empty()) + { + AttachTerminal(); + if (!LoadWalletFromSQLite(strWalletSQLite)) + { + FatalStartupError(fHeadlessStartup, + "Cannot open SQLite wallet export.", + strWalletLoadError); + return 1; + } + + fprintf(stderr, "SQLite wallet loaded: read-only staging backend\n"); + fprintf(stderr, "SQLite runtime writes are not enabled yet; exiting before " + "network, mining, GUI, or wallet mutation starts.\n"); + fflush(stderr); + DBFlush(true); + return 0; + } + if (!LoadWallet()) { // LoadWallet() explains itself into strWalletLoadError when it diff --git a/src/selftest.cpp b/src/selftest.cpp index feb4076..14bf45a 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1298,6 +1298,19 @@ static int RunWalletSQLiteMigrationSelfTest() "load check: ok"), "SQLite wallet export passes the loader compatibility check") ? 0 : 1; + string strRuntimeOut = tmp + "/sqlite-runtime.txt"; + vector vRuntimeArgs; + vRuntimeArgs.push_back("-datadir=" + strWalletDir); + vRuntimeArgs.push_back("-nomanagedtor"); + vRuntimeArgs.push_back("-nogui"); + vRuntimeArgs.push_back("-walletsqlite=" + strSQLite); + int nRuntimeRet = + RunBitflashChild(strExe, vRuntimeArgs, NULL, &strRuntimeOut); + nFail += Check(nRuntimeRet == 0 && + FileContainsText(strRuntimeOut, + "SQLite wallet loaded: read-only staging backend"), + "SQLite wallet export loads through the runtime startup path") ? 0 : 1; + string strRestoredDir = tmp + "/restored-wallet"; nFail += Check(MakeDirLocal(strRestoredDir), "restored wallet directory can be created") ? 0 : 1; @@ -1372,6 +1385,22 @@ static int RunWalletSQLiteMigrationSelfTest() "load check: failed"), "SQLite wallet load check rejects future wallet formats") ? 0 : 1; + string strFutureRuntimeOut = tmp + "/sqlite-runtime-future.txt"; + vector vFutureRuntimeArgs; + vFutureRuntimeArgs.push_back("-datadir=" + strWalletDir); + vFutureRuntimeArgs.push_back("-nomanagedtor"); + vFutureRuntimeArgs.push_back("-nogui"); + vFutureRuntimeArgs.push_back("-walletsqlite=" + strFutureSQLite); + int nFutureRuntimeRet = + RunBitflashChild(strExe, vFutureRuntimeArgs, NULL, + &strFutureRuntimeOut); + nFail += Check(nFutureRuntimeRet == 1 && + FileContainsText(strFutureRuntimeOut, + "Cannot open SQLite wallet export") && + FileContainsText(strFutureRuntimeOut, + "newer version of Bitflash"), + "SQLite runtime startup rejects future wallet formats") ? 0 : 1; + string strOverwriteOut = tmp + "/sqlite-export-overwrite.txt"; int nOverwriteRet = RunBitflashChild(strExe, vExportArgs, NULL, &strOverwriteOut); diff --git a/src/wallet_sqlite.cpp b/src/wallet_sqlite.cpp index 8489cac..ca81573 100644 --- a/src/wallet_sqlite.cpp +++ b/src/wallet_sqlite.cpp @@ -58,6 +58,25 @@ bool CWalletDBSQLite::Open(const string& strPath, string& strError) return true; } +bool CWalletDBSQLite::OpenReadOnly(const string& strPath, string& strError) +{ + Close(); + + int ret = sqlite3_open_v2(strPath.c_str(), &pdb, + SQLITE_OPEN_READONLY | + SQLITE_OPEN_FULLMUTEX, + NULL); + if (ret != SQLITE_OK) + { + SetSQLiteError(pdb, "cannot open SQLite wallet read-only", strError); + Close(); + return false; + } + + sqlite3_busy_timeout(pdb, 5000); + return true; +} + void CWalletDBSQLite::Close() { if (pdb) diff --git a/src/wallet_sqlite.h b/src/wallet_sqlite.h index 385a340..5e6467b 100644 --- a/src/wallet_sqlite.h +++ b/src/wallet_sqlite.h @@ -15,6 +15,7 @@ public: ~CWalletDBSQLite(); bool Open(const string& strPath, string& strError); + bool OpenReadOnly(const string& strPath, string& strError); void Close(); bool BeginTransaction(string& strError); -- cgit v1.2.3 From a72fc5fa0c9811ff5a57af90bf4440023c94ac5a Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Mon, 17 Aug 2026 10:50:00 -0300 Subject: wallet: add typed SQLite wallet records --- src/selftest.cpp | 51 ++++++++++++++++++++++++++++++++++ src/wallet_sqlite.cpp | 43 ++++++++++++++++++++++++++-- src/wallet_sqlite.h | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 167 insertions(+), 4 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/selftest.cpp b/src/selftest.cpp index 14bf45a..4b1e733 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1115,6 +1115,57 @@ static int RunWalletSQLiteSelfTest() nFail += Check(!ssReadDefault.fail() && vchReadPubKey == vchPubKey, "SQLite wallet preserves public key blobs") ? 0 : 1; } + + { + CWalletDBSQLite db; + nFail += Check(db.Open(strPath, strError), + "SQLite wallet reopens for typed records") ? 0 : 1; + + nFail += Check(db.WriteTypedRecord(make_pair(string("setting"), + string("sqlite-typed")), + (int64)777, strError), + "SQLite wallet writes a typed setting record") ? 0 : 1; + + int64 nTypedRead = 0; + nFail += Check(db.ReadTypedRecord(make_pair(string("setting"), + string("sqlite-typed")), + nTypedRead, strError) && + nTypedRead == 777, + "SQLite wallet reads a typed setting record") ? 0 : 1; + + nFail += Check(!db.WriteTypedRecord(make_pair(string("setting"), + string("sqlite-typed")), + (int64)888, strError, false), + "SQLite wallet refuses typed overwrite when asked") ? 0 : 1; + + nTypedRead = 0; + nFail += Check(db.ReadTypedRecord(make_pair(string("setting"), + string("sqlite-typed")), + nTypedRead, strError) && + nTypedRead == 777, + "SQLite wallet preserves the old typed value after overwrite refusal") ? 0 : 1; + + nFail += Check(db.EraseTypedRecord(make_pair(string("setting"), + string("sqlite-typed")), + strError), + "SQLite wallet erases a typed record") ? 0 : 1; + + nTypedRead = 0; + nFail += Check(!db.ReadTypedRecord(make_pair(string("setting"), + string("sqlite-typed")), + nTypedRead, strError), + "SQLite wallet typed erase removes the record") ? 0 : 1; + + nFail += Check(db.WriteTypedRecord(string("defaultkey"), vchPubKey, + strError), + "SQLite wallet writes a typed defaultkey record") ? 0 : 1; + + vector vchTypedPubKey; + nFail += Check(db.ReadTypedRecord(string("defaultkey"), vchTypedPubKey, + strError) && + vchTypedPubKey == vchPubKey, + "SQLite wallet reads a typed defaultkey record") ? 0 : 1; + } } catch (const std::exception& e) { diff --git a/src/wallet_sqlite.cpp b/src/wallet_sqlite.cpp index ca81573..f4b4b37 100644 --- a/src/wallet_sqlite.cpp +++ b/src/wallet_sqlite.cpp @@ -156,7 +156,8 @@ bool CWalletDBSQLite::InitSchema(string& strError) bool CWalletDBSQLite::WriteRecord(const vector& vchKey, const vector& vchValue, - string& strError) + string& strError, + bool fOverwrite) { if (!pdb) { @@ -170,8 +171,9 @@ bool CWalletDBSQLite::WriteRecord(const vector& vchKey, } sqlite3_stmt* stmt = NULL; - const char* pszSql = - "INSERT OR REPLACE INTO wallet_records(key, value) VALUES(?, ?);"; + const char* pszSql = fOverwrite + ? "INSERT OR REPLACE INTO wallet_records(key, value) VALUES(?, ?);" + : "INSERT INTO wallet_records(key, value) VALUES(?, ?);"; int ret = sqlite3_prepare_v2(pdb, pszSql, -1, &stmt, NULL); if (ret != SQLITE_OK) { @@ -250,6 +252,41 @@ bool CWalletDBSQLite::ReadRecord(const vector& vchKey, return false; } +bool CWalletDBSQLite::EraseRecord(const vector& vchKey, + string& strError) +{ + if (!pdb) + { + strError = "SQLite wallet is not open"; + return false; + } + if (vchKey.empty()) + { + strError = "SQLite wallet record key is empty"; + return false; + } + + sqlite3_stmt* stmt = NULL; + const char* pszSql = "DELETE FROM wallet_records WHERE key = ?;"; + int ret = sqlite3_prepare_v2(pdb, pszSql, -1, &stmt, NULL); + if (ret != SQLITE_OK) + { + SetSQLiteError(pdb, "cannot prepare SQLite wallet erase", strError); + return false; + } + + ret = sqlite3_bind_blob(stmt, 1, &vchKey[0], (int)vchKey.size(), + SQLITE_TRANSIENT); + if (ret == SQLITE_OK) + ret = sqlite3_step(stmt); + + bool fOk = (ret == SQLITE_DONE); + if (!fOk) + SetSQLiteError(pdb, "cannot erase SQLite wallet record", strError); + sqlite3_finalize(stmt); + return fOk; +} + bool CWalletDBSQLite::ScanRecords(CWalletRecordVisitor& visitor, string& strError) { diff --git a/src/wallet_sqlite.h b/src/wallet_sqlite.h index 5e6467b..d27e13a 100644 --- a/src/wallet_sqlite.h +++ b/src/wallet_sqlite.h @@ -25,13 +25,88 @@ public: bool WriteRecord(const vector& vchKey, const vector& vchValue, - string& strError); + string& strError, + bool fOverwrite=true); bool ReadRecord(const vector& vchKey, vector& vchValueRet, string& strError); + bool EraseRecord(const vector& vchKey, + string& strError); bool ScanRecords(CWalletRecordVisitor& visitor, string& strError); bool CountRecords(int& nRecordsRet, string& strError); + template + bool WriteTypedRecord(const K& key, const T& value, string& strError, + bool fOverwrite=true) + { + CDataStream ssKey(SER_DISK); + ssKey.reserve(1000); + ssKey << key; + + CDataStream ssValue(SER_DISK); + ssValue.reserve(10000); + ssValue << value; + + vector vchKey(ssKey.begin(), ssKey.end()); + vector vchValue(ssValue.begin(), ssValue.end()); + bool fOk = WriteRecord(vchKey, vchValue, strError, fOverwrite); + + if (!vchKey.empty()) + memset(&vchKey[0], 0, vchKey.size()); + if (!vchValue.empty()) + memset(&vchValue[0], 0, vchValue.size()); + if (!ssKey.empty()) + memset(&ssKey[0], 0, ssKey.size()); + if (!ssValue.empty()) + memset(&ssValue[0], 0, ssValue.size()); + return fOk; + } + + template + bool ReadTypedRecord(const K& key, T& valueRet, string& strError) + { + CDataStream ssKey(SER_DISK); + ssKey.reserve(1000); + ssKey << key; + vector vchKey(ssKey.begin(), ssKey.end()); + + vector vchValue; + bool fOk = ReadRecord(vchKey, vchValue, strError); + if (!vchKey.empty()) + memset(&vchKey[0], 0, vchKey.size()); + if (!ssKey.empty()) + memset(&ssKey[0], 0, ssKey.size()); + if (!fOk) + return false; + + CDataStream ssValue(vchValue, SER_DISK); + ssValue >> valueRet; + bool fReadOk = !ssValue.fail(); + if (!vchValue.empty()) + memset(&vchValue[0], 0, vchValue.size()); + if (!ssValue.empty()) + memset(&ssValue[0], 0, ssValue.size()); + if (!fReadOk) + strError = "SQLite wallet record could not be deserialized"; + return fReadOk; + } + + template + bool EraseTypedRecord(const K& key, string& strError) + { + CDataStream ssKey(SER_DISK); + ssKey.reserve(1000); + ssKey << key; + vector vchKey(ssKey.begin(), ssKey.end()); + + bool fOk = EraseRecord(vchKey, strError); + if (!vchKey.empty()) + memset(&vchKey[0], 0, vchKey.size()); + if (!ssKey.empty()) + memset(&ssKey[0], 0, ssKey.size()); + return fOk; + } + private: sqlite3* pdb; -- cgit v1.2.3 From 6c23bc3b0bb1643c5c287dfc9160cb5a8cdc37b6 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Mon, 17 Aug 2026 11:12:40 -0300 Subject: tests: prove typed SQLite wallets load --- src/selftest.cpp | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) (limited to 'src/selftest.cpp') diff --git a/src/selftest.cpp b/src/selftest.cpp index 4b1e733..a1efaa0 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1268,6 +1268,36 @@ static bool CreateSQLiteWalletWithFutureMinVersion(const string& strPath) return true; } +static bool CreateTypedSQLiteWallet(const string& strPath) +{ + string strError; + CWalletDBSQLite db; + CKey key; + key.MakeNewKey(); + + vector vchPubKey = key.GetPubKey(); + CPrivKey vchPrivKey = key.GetPrivKey(); + string strAddress = PubKeyToAddress(vchPubKey); + + if (!db.Open(strPath, strError)) + return false; + if (!db.BeginTransaction(strError)) + return false; + if (!db.WriteTypedRecord(make_pair(string("key"), vchPubKey), + vchPrivKey, strError, false)) + return false; + if (!db.WriteTypedRecord(string("defaultkey"), vchPubKey, strError)) + return false; + if (!db.WriteTypedRecord(make_pair(string("name"), strAddress), + string("Typed SQLite Address"), strError)) + return false; + if (!db.CommitTransaction(strError)) + return false; + if (!db.Checkpoint(strError)) + return false; + return true; +} + static int RunWalletSQLiteMigrationSelfTest() { fflush(stdout); @@ -1362,6 +1392,42 @@ static int RunWalletSQLiteMigrationSelfTest() "SQLite wallet loaded: read-only staging backend"), "SQLite wallet export loads through the runtime startup path") ? 0 : 1; + string strTypedSQLite = tmp + "/typed-wallet.sqlite"; + nFail += Check(CreateTypedSQLiteWallet(strTypedSQLite), + "typed SQLite wallet can be created without Berkeley DB") ? 0 : 1; + + string strTypedLoadCheckOut = tmp + "/sqlite-typed-loadcheck.txt"; + vector vTypedLoadCheckArgs; + vTypedLoadCheckArgs.push_back("-datadir=" + strWalletDir); + vTypedLoadCheckArgs.push_back("-nomanagedtor"); + vTypedLoadCheckArgs.push_back("-nogui"); + vTypedLoadCheckArgs.push_back("-walletsqliteloadcheck=" + strTypedSQLite); + int nTypedLoadCheckRet = + RunBitflashChild(strExe, vTypedLoadCheckArgs, NULL, + &strTypedLoadCheckOut); + nFail += Check(nTypedLoadCheckRet == 0 && + FileContainsText(strTypedLoadCheckOut, + "load check: ok"), + "typed SQLite wallet passes the loader compatibility check") ? 0 : 1; + + string strTypedRuntimeDir = tmp + "/typed-runtime-wallet"; + nFail += Check(MakeDirLocal(strTypedRuntimeDir), + "typed SQLite runtime directory can be created") ? 0 : 1; + string strTypedRuntimeOut = tmp + "/sqlite-typed-runtime.txt"; + vector vTypedRuntimeArgs; + vTypedRuntimeArgs.push_back("-datadir=" + strTypedRuntimeDir); + vTypedRuntimeArgs.push_back("-nomanagedtor"); + vTypedRuntimeArgs.push_back("-nogui"); + vTypedRuntimeArgs.push_back("-walletsqlite=" + strTypedSQLite); + int nTypedRuntimeRet = + RunBitflashChild(strExe, vTypedRuntimeArgs, NULL, + &strTypedRuntimeOut); + nFail += Check(nTypedRuntimeRet == 0 && + FileContainsText(strTypedRuntimeOut, + "SQLite wallet loaded: read-only staging backend") && + !FileExists((strTypedRuntimeDir + "/wallet.dat").c_str()), + "typed SQLite wallet loads through runtime without creating wallet.dat") ? 0 : 1; + string strRestoredDir = tmp + "/restored-wallet"; nFail += Check(MakeDirLocal(strRestoredDir), "restored wallet directory can be created") ? 0 : 1; -- cgit v1.2.3 From d082210c5d080c0a91412b94a109d8e73422eb31 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Mon, 17 Aug 2026 11:32:45 -0300 Subject: wallet: route CWalletDB through SQLite runtime --- src/db.cpp | 67 +++++++++++++++++++++++++ src/db.h | 145 ++++++++++++++++++++++++++++++++++++++++++++----------- src/selftest.cpp | 76 +++++++++++++++++++++++++++++ 3 files changed, 259 insertions(+), 29 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/db.cpp b/src/db.cpp index 581e5a7..7cfb7c5 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -56,6 +56,7 @@ string strWalletLoadError; static DbEnv* pdbenv = NULL; static FILE* pdbErrFile = NULL; static map mapFileUseCount; +static CWalletDBSQLite* pWalletSQLiteRuntime = NULL; DbEnv& GetDbEnv() { @@ -95,6 +96,67 @@ public: } instance_of_cdbinit; +bool WalletSQLiteRuntimeActive() +{ + return pWalletSQLiteRuntime != NULL; +} + +bool WalletSQLiteRuntimeOpen(const string& strPath, string& strErrorRet) +{ + strErrorRet.clear(); + if (!FileExists(strPath.c_str())) + { + strErrorRet = strprintf("SQLite wallet does not exist: %s", + strPath.c_str()); + return false; + } + + WalletSQLiteRuntimeClose(); + std::auto_ptr db(new CWalletDBSQLite()); + if (!db->Open(strPath, strErrorRet)) + return false; + + pWalletSQLiteRuntime = db.release(); + return true; +} + +void WalletSQLiteRuntimeClose() +{ + if (pWalletSQLiteRuntime) + { + delete pWalletSQLiteRuntime; + pWalletSQLiteRuntime = NULL; + } +} + +bool WalletSQLiteRuntimeReadRecord(const vector& vchKey, + vector& vchValueRet) +{ + if (!pWalletSQLiteRuntime) + return false; + string strError; + return pWalletSQLiteRuntime->ReadRecord(vchKey, vchValueRet, strError); +} + +bool WalletSQLiteRuntimeWriteRecord(const vector& vchKey, + const vector& vchValue, + bool fOverwrite) +{ + if (!pWalletSQLiteRuntime) + return false; + string strError; + return pWalletSQLiteRuntime->WriteRecord(vchKey, vchValue, strError, + fOverwrite); +} + +bool WalletSQLiteRuntimeEraseRecord(const vector& vchKey) +{ + if (!pWalletSQLiteRuntime) + return false; + string strError; + return pWalletSQLiteRuntime->EraseRecord(vchKey, strError); +} + CDB::CDB(const char* pszFile, const char* pszMode, bool fTxn) : pdb(NULL) { @@ -651,6 +713,11 @@ bool CReviewDB::WriteReviews(uint256 hash, const vector& vReviews) // CWalletDB // +CWalletDB::CWalletDB(const char* pszMode, bool fTxn) : + CDB(WalletSQLiteRuntimeActive() ? NULL : "wallet.dat", pszMode, fTxn) +{ +} + class CSQLiteWalletRuntimeLoadVisitor : public CWalletRecordVisitor { public: diff --git a/src/db.h b/src/db.h index 59ff24a..8fa4ea8 100644 --- a/src/db.h +++ b/src/db.h @@ -356,6 +356,16 @@ private: // Legacy IP-based peer address database (CAddrDB / addr.dat) removed -- // peer discovery is entirely Nostr/.btf-based now. +bool WalletSQLiteRuntimeActive(); +bool WalletSQLiteRuntimeOpen(const string& strPath, string& strErrorRet); +void WalletSQLiteRuntimeClose(); +bool WalletSQLiteRuntimeReadRecord(const vector& vchKey, + vector& vchValueRet); +bool WalletSQLiteRuntimeWriteRecord(const vector& vchKey, + const vector& vchValue, + bool fOverwrite); +bool WalletSQLiteRuntimeEraseRecord(const vector& vchKey); + @@ -363,64 +373,141 @@ private: class CWalletDB : public CDB { public: - CWalletDB(const char* pszMode="r+", bool fTxn=false) : CDB("wallet.dat", pszMode, fTxn) { } + CWalletDB(const char* pszMode="r+", bool fTxn=false); private: CWalletDB(const CWalletDB&); void operator=(const CWalletDB&); + + template + bool ReadWalletRecord(const K& key, T& value) + { + if (!WalletSQLiteRuntimeActive()) + return Read(key, value); + + CDataStream ssKey(SER_DISK); + ssKey.reserve(1000); + ssKey << key; + vector vchKey(ssKey.begin(), ssKey.end()); + vector vchValue; + + bool fOk = WalletSQLiteRuntimeReadRecord(vchKey, vchValue); + if (!vchKey.empty()) + memset(&vchKey[0], 0, vchKey.size()); + if (!ssKey.empty()) + memset(&ssKey[0], 0, ssKey.size()); + if (!fOk) + return false; + + CDataStream ssValue(vchValue, SER_DISK); + ssValue >> value; + bool fReadOk = !ssValue.fail(); + if (!vchValue.empty()) + memset(&vchValue[0], 0, vchValue.size()); + if (!ssValue.empty()) + memset(&ssValue[0], 0, ssValue.size()); + return fReadOk; + } + + template + bool WriteWalletRecord(const K& key, const T& value, bool fOverwrite=true) + { + if (!WalletSQLiteRuntimeActive()) + return Write(key, value, fOverwrite); + + CDataStream ssKey(SER_DISK); + ssKey.reserve(1000); + ssKey << key; + CDataStream ssValue(SER_DISK); + ssValue.reserve(10000); + ssValue << value; + + vector vchKey(ssKey.begin(), ssKey.end()); + vector vchValue(ssValue.begin(), ssValue.end()); + bool fOk = WalletSQLiteRuntimeWriteRecord(vchKey, vchValue, fOverwrite); + + if (!vchKey.empty()) + memset(&vchKey[0], 0, vchKey.size()); + if (!vchValue.empty()) + memset(&vchValue[0], 0, vchValue.size()); + if (!ssKey.empty()) + memset(&ssKey[0], 0, ssKey.size()); + if (!ssValue.empty()) + memset(&ssValue[0], 0, ssValue.size()); + return fOk; + } + + template + bool EraseWalletRecord(const K& key) + { + if (!WalletSQLiteRuntimeActive()) + return Erase(key); + + CDataStream ssKey(SER_DISK); + ssKey.reserve(1000); + ssKey << key; + vector vchKey(ssKey.begin(), ssKey.end()); + bool fOk = WalletSQLiteRuntimeEraseRecord(vchKey); + + if (!vchKey.empty()) + memset(&vchKey[0], 0, vchKey.size()); + if (!ssKey.empty()) + memset(&ssKey[0], 0, ssKey.size()); + return fOk; + } public: bool ReadName(const string& strAddress, string& strName) { strName = ""; - return Read(make_pair(string("name"), strAddress), strName); + return ReadWalletRecord(make_pair(string("name"), strAddress), strName); } bool WriteName(const string& strAddress, const string& strName) { mapAddressBook[strAddress] = strName; - return Write(make_pair(string("name"), strAddress), strName); + return WriteWalletRecord(make_pair(string("name"), strAddress), strName); } bool EraseName(const string& strAddress) { mapAddressBook.erase(strAddress); - return Erase(make_pair(string("name"), strAddress)); + return EraseWalletRecord(make_pair(string("name"), strAddress)); } bool ReadTx(uint256 hash, CWalletTx& wtx) { - return Read(make_pair(string("tx"), hash), wtx); + return ReadWalletRecord(make_pair(string("tx"), hash), wtx); } bool WriteTx(uint256 hash, const CWalletTx& wtx) { - return Write(make_pair(string("tx"), hash), wtx); + return WriteWalletRecord(make_pair(string("tx"), hash), wtx); } bool EraseTx(uint256 hash) { - return Erase(make_pair(string("tx"), hash)); + return EraseWalletRecord(make_pair(string("tx"), hash)); } bool ReadKey(const vector& vchPubKey, CPrivKey& vchPrivKey) { vchPrivKey.clear(); - return Read(make_pair(string("key"), vchPubKey), vchPrivKey); + return ReadWalletRecord(make_pair(string("key"), vchPubKey), vchPrivKey); } bool WriteKey(const vector& vchPubKey, const CPrivKey& vchPrivKey) { - return Write(make_pair(string("key"), vchPubKey), vchPrivKey, false); + return WriteWalletRecord(make_pair(string("key"), vchPubKey), vchPrivKey, false); } bool ReadDefaultKey(vector& vchPubKey) { vchPubKey.clear(); - return Read(string("defaultkey"), vchPubKey); + return ReadWalletRecord(string("defaultkey"), vchPubKey); } bool WriteDefaultKey(const vector& vchPubKey) { - return Write(string("defaultkey"), vchPubKey); + return WriteWalletRecord(string("defaultkey"), vchPubKey); } // --- Deterministic (BIP32) seed --------------------------------------- @@ -438,62 +525,62 @@ public: { vchMasterRet.clear(); vchChainCodeRet.clear(); - return Read(string("hdmaster"), vchMasterRet) - && Read(string("hdchaincode"), vchChainCodeRet); + return ReadWalletRecord(string("hdmaster"), vchMasterRet) + && ReadWalletRecord(string("hdchaincode"), vchChainCodeRet); } bool WriteHDMaster(const vector& vchMaster, const vector& vchChainCode) { - return Write(string("hdmaster"), vchMaster) - && Write(string("hdchaincode"), vchChainCode); + return WriteWalletRecord(string("hdmaster"), vchMaster) + && WriteWalletRecord(string("hdchaincode"), vchChainCode); } bool WriteWalletMinVersion(int nVersion) { - return Write(string("walletminversion"), nVersion); + return WriteWalletRecord(string("walletminversion"), nVersion); } bool WriteMasterKey(unsigned int nID, const CWalletMasterKey& kMasterKey) { - return Write(make_pair(string("mkey"), nID), kMasterKey, true); + return WriteWalletRecord(make_pair(string("mkey"), nID), kMasterKey, true); } bool WriteCryptedKey(const vector& vchPubKey, const vector& vchCryptedSecret) { - return Write(make_pair(string("ckey"), vchPubKey), vchCryptedSecret, false); + return WriteWalletRecord(make_pair(string("ckey"), vchPubKey), vchCryptedSecret, false); } bool WriteCryptedHDMaster(const vector& vchCryptedMaster, const vector& vchCryptedChainCode) { - return Write(string("cryptedhdmaster"), vchCryptedMaster) - && Write(string("cryptedhdchaincode"), vchCryptedChainCode); + return WriteWalletRecord(string("cryptedhdmaster"), vchCryptedMaster) + && WriteWalletRecord(string("cryptedhdchaincode"), vchCryptedChainCode); } bool WriteHDNext(unsigned int nNext) { - return Write(string("hdnext"), nNext); + return WriteWalletRecord(string("hdnext"), nNext); } bool WriteHDSchema(int nSchema) { - return Write(string("hdschema"), nSchema); + return WriteWalletRecord(string("hdschema"), nSchema); } bool WriteHDCoinType(unsigned int nCoinType) { - return Write(string("hdcointype"), nCoinType); + return WriteWalletRecord(string("hdcointype"), nCoinType); } bool WriteHDReceiveNext(unsigned int nNext) { - return Write(string("hdreceivenext"), nNext); + return WriteWalletRecord(string("hdreceivenext"), nNext); } bool WriteHDChangeNext(unsigned int nNext) { - return Write(string("hdchangenext"), nNext); + return WriteWalletRecord(string("hdchangenext"), nNext); } // Keys generated ahead of time and not yet handed out. The private key of @@ -501,24 +588,24 @@ public: // record only says that this one is still unspoken for. bool WritePool(int64 nIndex, const vector& vchPubKey) { - return Write(make_pair(string("pool"), nIndex), vchPubKey); + return WriteWalletRecord(make_pair(string("pool"), nIndex), vchPubKey); } bool ErasePool(int64 nIndex) { - return Erase(make_pair(string("pool"), nIndex)); + return EraseWalletRecord(make_pair(string("pool"), nIndex)); } template bool ReadSetting(const string& strKey, T& value) { - return Read(make_pair(string("setting"), strKey), value); + return ReadWalletRecord(make_pair(string("setting"), strKey), value); } template bool WriteSetting(const string& strKey, const T& value) { - return Write(make_pair(string("setting"), strKey), value); + return WriteWalletRecord(make_pair(string("setting"), strKey), value); } bool LoadWallet(vector& vchDefaultKeyRet); diff --git a/src/selftest.cpp b/src/selftest.cpp index a1efaa0..9fa0938 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1051,6 +1051,8 @@ static int RunWalletSQLiteSelfTest() int nFail = 0; string strPath = tmp + "/wallet.sqlite"; string strError; + string strOldRuntimeDataDir; + bool fRestoreRuntimeDataDir = false; try { @@ -1166,14 +1168,88 @@ static int RunWalletSQLiteSelfTest() vchTypedPubKey == vchPubKey, "SQLite wallet reads a typed defaultkey record") ? 0 : 1; } + + { + string strRuntimePath = tmp + "/runtime-wallet.sqlite"; + string strRuntimeDir = tmp + "/runtime-datadir"; + nFail += Check(MakeDirLocal(strRuntimeDir), + "SQLite runtime datadir can be created") ? 0 : 1; + + { + CWalletDBSQLite db; + nFail += Check(db.Open(strRuntimePath, strError), + "SQLite runtime wallet file can be created") ? 0 : 1; + } + + string strOldDataDir = strSetDataDir; + strSetDataDir = strRuntimeDir; + strOldRuntimeDataDir = strOldDataDir; + fRestoreRuntimeDataDir = true; + bool fRuntimeOpened = WalletSQLiteRuntimeOpen(strRuntimePath, strError); + nFail += Check(fRuntimeOpened, + "SQLite runtime wallet can be activated") ? 0 : 1; + + if (fRuntimeOpened) + { + CKey key; + key.MakeNewKey(); + vector vchRuntimePubKey = key.GetPubKey(); + CPrivKey vchRuntimePrivKey = key.GetPrivKey(); + string strRuntimeAddress = PubKeyToAddress(vchRuntimePubKey); + + nFail += Check(CWalletDB().WriteSetting("runtime-sqlite-setting", + (int64)909090) && + CWalletDB().WriteDefaultKey(vchRuntimePubKey) && + CWalletDB().WriteKey(vchRuntimePubKey, + vchRuntimePrivKey) && + CWalletDB().WriteName(strRuntimeAddress, + "SQLite Runtime Address"), + "CWalletDB writes route to active SQLite runtime") ? 0 : 1; + + int64 nRuntimeSetting = 0; + vector vchRuntimeDefaultKey; + CPrivKey vchRuntimeReadPrivKey; + string strRuntimeName; + nFail += Check(CWalletDB("r").ReadSetting("runtime-sqlite-setting", + nRuntimeSetting) && + nRuntimeSetting == 909090 && + CWalletDB("r").ReadDefaultKey(vchRuntimeDefaultKey) && + vchRuntimeDefaultKey == vchRuntimePubKey && + CWalletDB("r").ReadKey(vchRuntimePubKey, + vchRuntimeReadPrivKey) && + vchRuntimeReadPrivKey == vchRuntimePrivKey && + CWalletDB("r").ReadName(strRuntimeAddress, + strRuntimeName) && + strRuntimeName == "SQLite Runtime Address", + "CWalletDB reads route to active SQLite runtime") ? 0 : 1; + + nFail += Check(CWalletDB().EraseName(strRuntimeAddress) && + !CWalletDB("r").ReadName(strRuntimeAddress, + strRuntimeName), + "CWalletDB erases route to active SQLite runtime") ? 0 : 1; + + nFail += Check(!FileExists((strRuntimeDir + "/wallet.dat").c_str()), + "active SQLite runtime does not create wallet.dat") ? 0 : 1; + } + + WalletSQLiteRuntimeClose(); + strSetDataDir = strOldDataDir; + fRestoreRuntimeDataDir = false; + } } catch (const std::exception& e) { + WalletSQLiteRuntimeClose(); + if (fRestoreRuntimeDataDir) + strSetDataDir = strOldRuntimeDataDir; printf(" FAIL exception: %s\n", e.what()); nFail++; } catch (...) { + WalletSQLiteRuntimeClose(); + if (fRestoreRuntimeDataDir) + strSetDataDir = strOldRuntimeDataDir; printf(" FAIL unknown exception\n"); nFail++; } -- cgit v1.2.3 From 2a2b9d74f8fb6b5d86d750e6e5d18c942738f95c Mon Sep 17 00:00:00 2001 From: bitflash Date: Mon, 17 Aug 2026 17:45:01 -0300 Subject: wallet: run the node on a SQLite backend (opt-in) Adds -walletbackend=sqlite: an opt-in flag that runs the node on /wallet.sqlite instead of wallet.dat. It requires an export to exist first (no silent migration) and prints a step-by-step guide when one does not; wallet.dat is never opened or touched, so a plain restart goes back to Berkeley DB. - LoadWalletFromSQLiteRuntime opens the export read-write, keeps the handle open as the active backend, loads records into memory through the same visitor/reconcile/finish path as the read-only staging load, and never fabricates a default key. - Shutdown checkpoints and closes the SQLite backend; a kill before that loses nothing because WAL + synchronous=FULL already made every committed write durable. - Self-test proves runtime writes persist across a close and reopen. BDB stays the default: nothing activates the SQLite runtime unless the flag is given. One-shot audit/recovery commands still use Berkeley DB. --- src/db.cpp | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/db.h | 1 + src/main_gui.cpp | 66 +++++++++++++++++++++++++++++++++++++--- src/selftest.cpp | 25 ++++++++++++++++ 4 files changed, 179 insertions(+), 4 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/db.cpp b/src/db.cpp index 7cfb7c5..c4ea81e 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -124,6 +124,13 @@ void WalletSQLiteRuntimeClose() { if (pWalletSQLiteRuntime) { + // Fold the write-ahead log into the main file on the way out. WAL + + // synchronous=FULL already makes every committed write durable across a + // kill, so this is housekeeping, not the thing that protects the data: + // if it fails, or the node is killed before it runs, the wallet is + // still whole. Best effort, never throws. + string strCheckpointError; + pWalletSQLiteRuntime->Checkpoint(strCheckpointError); delete pWalletSQLiteRuntime; pWalletSQLiteRuntime = NULL; } @@ -1426,6 +1433,90 @@ bool LoadWalletFromSQLite(const string& strPath) return FinishLoadedWallet(visitor.vchDefaultKey, false); } +// Open a SQLite wallet read-write and keep it open as the active runtime +// backend, then load its records into memory the same way LoadWalletFromSQLite +// does. Unlike the read-only staging path, this leaves pWalletSQLiteRuntime set +// so every later CWalletDB read/write/erase routes to this file for the life of +// the process. The Berkeley DB wallet.dat is never opened. +bool LoadWalletFromSQLiteRuntime(const string& strPath) +{ + strWalletLoadError.clear(); + + if (!FileExists(strPath.c_str())) + { + strWalletLoadError = strprintf("SQLite wallet does not exist: %s", + strPath.c_str()); + printf("LoadWallet: %s\n", strWalletLoadError.c_str()); + return false; + } + + string strError; + if (!WalletSQLiteRuntimeOpen(strPath, strError)) + { + strWalletLoadError = strError; + printf("LoadWallet: cannot open SQLite wallet backend: %s\n", + strError.c_str()); + return false; + } + + CSQLiteWalletRuntimeLoadVisitor visitor; + try + { + CRITICAL_BLOCK(cs_mapKeys) + CRITICAL_BLOCK(cs_mapWallet) + { + if (!pWalletSQLiteRuntime->ScanRecords(visitor, strError)) + { + WalletSQLiteRuntimeClose(); + if (strWalletLoadError.empty()) + strWalletLoadError = strError; + printf("LoadWallet: SQLite wallet backend could not be read. " + "Failed while handling a '%s' record: %s\n", + visitor.strLastType.c_str(), strError.c_str()); + return false; + } + } + } + catch (const std::exception& e) + { + WalletSQLiteRuntimeClose(); + strWalletLoadError = strprintf( + "SQLite wallet backend could not be read while handling a '%s' record: %s", + visitor.strLastType.c_str(), e.what()); + printf("LoadWallet: %s\n", strWalletLoadError.c_str()); + return false; + } + catch (...) + { + WalletSQLiteRuntimeClose(); + strWalletLoadError = strprintf( + "SQLite wallet backend could not be read while handling a '%s' record", + visitor.strLastType.c_str()); + printf("LoadWallet: %s\n", strWalletLoadError.c_str()); + return false; + } + + if (!ReconcileLoadedWalletMetadata(visitor.fHaveStoredMineMode, + visitor.fHaveStoredHDCoinType)) + { + WalletSQLiteRuntimeClose(); + if (strWalletLoadError.empty()) + strWalletLoadError = "SQLite wallet metadata is not supported by this build"; + return false; + } + + // Never fabricate a default key: an export always carries one, and a file + // that does not is refused with the wallet left open to no writes rather + // than quietly minted into a new identity. + if (!FinishLoadedWallet(visitor.vchDefaultKey, false)) + { + WalletSQLiteRuntimeClose(); + return false; + } + + return true; +} + // Write a copy of wallet.dat that can be opened anywhere. // diff --git a/src/db.h b/src/db.h index 8fa4ea8..fee5a82 100644 --- a/src/db.h +++ b/src/db.h @@ -624,6 +624,7 @@ bool ScanWalletRecords(CWalletRecordVisitor& visitor, string& strErrorRet); bool LoadWallet(); bool LoadWalletFromSQLite(const string& strPath); +bool LoadWalletFromSQLiteRuntime(const string& strPath); // Writes a wallet.dat that opens on its own, anywhere -- see the definition in // db.cpp for why a plain file copy does not. Still a point-in-time snapshot: diff --git a/src/main_gui.cpp b/src/main_gui.cpp index ee875c7..4d77533 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -190,6 +190,8 @@ static void PrintUsage() printf(" /walletsqliteloadcheck=FILE\n"); printf(" (parse a SQLite export like the wallet loader)\n"); printf(" /walletsqlite=FILE (stage-load a SQLite wallet export, then exit)\n"); + printf(" /walletbackend=sqlite (opt-in: run the node on /wallet.sqlite\n"); + printf(" instead of wallet.dat; export one first)\n"); printf(" /rescan (walk the chain for coins this wallet owns\n"); printf(" but never recorded, then exit)\n"); printf("\n"); @@ -618,9 +620,59 @@ int main(int argc, char* argv[]) printf("Loading wallet...\n"); try { + string strWalletBackend = + argval2(argc, argv, "/walletbackend", "-walletbackend"); string strWalletSQLite = argval2(argc, argv, "/walletsqlite", "-walletsqlite"); - if (!strWalletSQLite.empty()) + + if (strWalletBackend == "sqlite") + { + // Opt-in, experimental. The node runs on /wallet.sqlite and + // never opens wallet.dat, but the user has to have exported one + // first -- there is no silent migration, and wallet.dat is left + // exactly where it is so a plain restart goes back to it. + AttachTerminal(); + string strSQLitePath = GetAppDir() + "/wallet.sqlite"; + if (!FileExists(strSQLitePath.c_str())) + { + string strGuide = strprintf( + "No SQLite wallet was found at\n" + " %s\n\n" + "The SQLite wallet backend is opt-in and needs an exported\n" + "wallet first. Your Berkeley DB wallet.dat is not touched by\n" + "any of this.\n\n" + " Step 1 Export your existing wallet to SQLite:\n" + " bitflash -walletsqliteexport=\"%s\"\n" + " Step 2 Start again with the SQLite backend:\n" + " bitflash -walletbackend=sqlite\n\n" + "To go back to Berkeley DB at any time, just start without\n" + "-walletbackend=sqlite. wallet.dat is still your wallet.", + strSQLitePath.c_str(), strSQLitePath.c_str()); + FatalStartupError(fHeadlessStartup, + "The SQLite wallet backend needs an exported wallet first.", + strGuide); + return 1; + } + + if (!LoadWalletFromSQLiteRuntime(strSQLitePath)) + { + FatalStartupError(fHeadlessStartup, + "Cannot open the SQLite wallet backend.", + strWalletLoadError); + return 1; + } + + fprintf(stderr, + "Wallet backend: SQLite (experimental)\n" + " %s\n" + "Your Berkeley DB wallet.dat is left untouched. Restart without\n" + "-walletbackend=sqlite to go back to it.\n", + strSQLitePath.c_str()); + fflush(stderr); + // Fall through and run the node normally: every CWalletDB read, + // write, and erase now routes to this SQLite file. + } + else if (!strWalletSQLite.empty()) { AttachTerminal(); if (!LoadWalletFromSQLite(strWalletSQLite)) @@ -638,8 +690,7 @@ int main(int argc, char* argv[]) DBFlush(true); return 0; } - - if (!LoadWallet()) + else if (!LoadWallet()) { // LoadWallet() explains itself into strWalletLoadError when it // knows why -- an unsupported wallet format, for one, which is the @@ -650,7 +701,9 @@ int main(int argc, char* argv[]) } // After the block index, so there is a chain to compare the wallet - // against, and before anything reports a balance. + // against, and before anything reports a balance. Works on either + // backend: RescanSpentFlags writes corrected flags through CWalletDB, + // which routes to SQLite when that backend is active. RescanSpentFlags(); } catch (const std::exception& e) @@ -894,6 +947,10 @@ int main(int argc, char* argv[]) // called here, which is why a wallet.dat copied elsewhere would not // open -- issue #40. Not optional. DBFlush(true); + // Checkpoint and close the SQLite wallet backend if one is active. A + // no-op on the Berkeley DB path. Safe to kill before this runs: WAL + + // synchronous=FULL already made every committed write durable. + WalletSQLiteRuntimeClose(); #ifdef _WIN32 // wallet.dat is safely flushed and closed now; let a pending console // control handler (CLOSE/LOGOFF/SHUTDOWN) return so the OS can finish. @@ -910,6 +967,7 @@ int main(int argc, char* argv[]) StopNode(); BtfStopManagedTor(); DBFlush(true); + WalletSQLiteRuntimeClose(); return ret; #endif } diff --git a/src/selftest.cpp b/src/selftest.cpp index 9fa0938..098eed6 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1230,6 +1230,31 @@ static int RunWalletSQLiteSelfTest() nFail += Check(!FileExists((strRuntimeDir + "/wallet.dat").c_str()), "active SQLite runtime does not create wallet.dat") ? 0 : 1; + + // Durability: close the backend (checkpoints the WAL), reopen + // the same file, and confirm what CWalletDB wrote is still + // there. This is the write-survives-a-restart property the real + // -walletbackend=sqlite node depends on. + WalletSQLiteRuntimeClose(); + bool fReopened = + WalletSQLiteRuntimeOpen(strRuntimePath, strError); + nFail += Check(fReopened, + "SQLite runtime wallet reopens after close") ? 0 : 1; + if (fReopened) + { + int64 nPersisted = 0; + CPrivKey vchPersistedPrivKey; + vector vchPersistedDefaultKey; + nFail += Check(CWalletDB("r").ReadSetting("runtime-sqlite-setting", + nPersisted) && + nPersisted == 909090 && + CWalletDB("r").ReadDefaultKey(vchPersistedDefaultKey) && + vchPersistedDefaultKey == vchRuntimePubKey && + CWalletDB("r").ReadKey(vchRuntimePubKey, + vchPersistedPrivKey) && + vchPersistedPrivKey == vchRuntimePrivKey, + "SQLite runtime writes persist across a close and reopen") ? 0 : 1; + } } WalletSQLiteRuntimeClose(); -- cgit v1.2.3 From e58c4022e0ddd137b93cdb3464753bef5df1d456 Mon Sep 17 00:00:00 2001 From: bitflash Date: Tue, 18 Aug 2026 14:23:38 -0300 Subject: wallet: encrypt atomically under the SQLite backend EncryptWallet() had only a Berkeley DB path: it rebuilt a wallet.dat temp file and swapped it in. Under -walletbackend=sqlite that was wrong -- it would leave the live wallet.sqlite unencrypted and write a divergent wallet.dat instead. Add a SQLite persistence branch that rebuilds a fresh wallet.sqlite in one transaction (public records copied from the live store, encrypted key material serialized byte-for-byte the way the BDB path writes it), checkpoints it, and swaps it in with a .before-encrypt backup, mirroring the BDB rename dance. A fresh file rather than an in-place rewrite keeps the change atomic and leaves no plaintext key in a freed page. CmdEncryptWallet skips PurgeDbEnvironmentLogs under SQLite (no Berkeley DB environment behind the wallet) and reports the backend it used. New wallet-sqlite-encrypt self-test drives the real command path in child processes: export a wallet to SQLite, encrypt it through -walletbackend=sqlite, and prove it comes out encrypted, unlockable with the right passphrase, refusing the wrong one, with its recovery phrase intact and the original wallet.dat left untouched. --- src/db.cpp | 210 ++++++++++++++++++++++++++++++++++++++++++++++++++++- src/makefile.mingw | 1 + src/selftest.cpp | 189 ++++++++++++++++++++++++++++++++++++++++++++++- src/walletcmd.cpp | 15 +++- 4 files changed, 410 insertions(+), 5 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/db.cpp b/src/db.cpp index 771a4ba..86f6dd7 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -1974,9 +1974,9 @@ static bool CopyPublicWalletRecordsTo(CWalletRewriteDB& dbTo, return ScanWalletRecords(visitor, strErrorRet); } -static string WalletEncryptBackupPath() +static string WalletEncryptBackupPathFor(const char* pszBaseName) { - string strBase = GetAppDir() + "/wallet.dat.before-encrypt."; + string strBase = GetAppDir() + "/" + pszBaseName + ".before-encrypt."; string strPath = strBase + i64tostr(GetTime()) + ".bak"; for (int i = 1; FileExists(strPath.c_str()); i++) strPath = strBase + i64tostr(GetTime()) + "." + itostr(i) + ".bak"; @@ -2006,6 +2006,192 @@ static void SecureClearBytes(vector& vch) vch.clear(); } +// The SQLite counterpart of CWalletPublicRecordCopyVisitor: same skip list, +// same byte-for-byte copy, but the destination is a CWalletDBSQLite record +// store rather than a Berkeley DB file. +class CWalletPublicRecordCopyToSQLiteVisitor : public CWalletRecordVisitor +{ +public: + CWalletDBSQLite& dbTo; + int& nCopied; + + CWalletPublicRecordCopyToSQLiteVisitor(CWalletDBSQLite& dbToIn, int& nCopiedIn) + : dbTo(dbToIn), nCopied(nCopiedIn) { } + + bool VisitWalletRecord(const CDataStream& ssKey, + const CDataStream& ssValue, + string& strErrorRet) + { + string strType; + try + { + CDataStream ssType = ssKey; + ssType >> strType; + } + catch (...) + { + strErrorRet = "SQLite wallet has a record whose key type cannot be read"; + return false; + } + + if (IsWalletEncryptionPrivateRecord(strType)) + return true; + + vector vchKey(ssKey.begin(), ssKey.end()); + vector vchValue(ssValue.begin(), ssValue.end()); + bool fOk = dbTo.WriteRecord(vchKey, vchValue, strErrorRet, true); + if (!vchKey.empty()) + memset(&vchKey[0], 0, vchKey.size()); + if (!vchValue.empty()) + memset(&vchValue[0], 0, vchValue.size()); + if (!fOk) + { + strErrorRet = strprintf("could not copy wallet record '%s': %s", + strType.c_str(), strErrorRet.c_str()); + return false; + } + nCopied++; + return true; + } +}; + +// Persist an encrypted wallet under the SQLite backend. Everything the caller +// computed -- the master key, the re-encrypted private keys, the fresh crypted +// key pool, the encrypted HD seed -- lands in a brand new wallet.sqlite built +// in one transaction, which is then swapped in for the plaintext original. +// +// A fresh file rather than an in-place rewrite for two reasons: it keeps the +// whole change atomic (a crash before the rename leaves the old wallet whole), +// and it guarantees no plaintext key survives in a freed page the way an +// in-place DELETE would leave behind until a VACUUM. +static bool EncryptWalletPersistSQLite( + const vector, vector > >& vCryptedKeys, + const vector, vector > >& vNewPoolKeys, + const vector > >& vNewPool, + const CWalletMasterKey& kMasterKey, + const vector& vchCryptedHDMasterNew, + const vector& vchCryptedHDChainCodeNew, + unsigned int nHDNextNew, + int& nCopiedRet, + string& strBackupRet, + string& strErrorRet) +{ + nCopiedRet = 0; + if (!pWalletSQLiteRuntime) + { + strErrorRet = "SQLite wallet backend is not active"; + return false; + } + + string strWalletPath = GetAppDir() + "/wallet.sqlite"; + string strTempPath = GetAppDir() + "/" + + strprintf("wallet.sqlite.encrypting.%lld", GetTime()); + remove(strTempPath.c_str()); + remove((strTempPath + "-wal").c_str()); + remove((strTempPath + "-shm").c_str()); + + { + CWalletDBSQLite dbTmp; + if (!dbTmp.Open(strTempPath, strErrorRet)) + { + remove(strTempPath.c_str()); + return false; + } + if (!dbTmp.BeginTransaction(strErrorRet)) + { + dbTmp.Close(); + remove(strTempPath.c_str()); + return false; + } + + // The public records come from the live SQLite store -- under this + // backend ScanWalletRecords() has no Berkeley DB behind it to read. + CWalletPublicRecordCopyToSQLiteVisitor visitor(dbTmp, nCopiedRet); + bool fOk = pWalletSQLiteRuntime->ScanRecords(visitor, strErrorRet); + + // Encrypted metadata, serialized byte-for-byte the way the Berkeley DB + // path (CWalletRewriteDB) writes it, so a wallet encrypted under either + // backend is the same set of records. ckey uses INSERT-only to match + // WriteCryptedKey; the rest overwrite. + fOk = fOk && + dbTmp.WriteTypedRecord(string("walletminversion"), + (int)WALLET_FORMAT_ENCRYPTED, strErrorRet, true) && + dbTmp.WriteTypedRecord(make_pair(string("mkey"), + (unsigned int)(nWalletMasterKeyMaxID + 1)), + kMasterKey, strErrorRet, true); + for (size_t i = 0; fOk && i < vCryptedKeys.size(); i++) + fOk = dbTmp.WriteTypedRecord(make_pair(string("ckey"), vCryptedKeys[i].first), + vCryptedKeys[i].second, strErrorRet, false); + for (size_t i = 0; fOk && i < vNewPoolKeys.size(); i++) + fOk = dbTmp.WriteTypedRecord(make_pair(string("ckey"), vNewPoolKeys[i].first), + vNewPoolKeys[i].second, strErrorRet, false); + if (fOk && !vchCryptedHDMasterNew.empty()) + fOk = dbTmp.WriteTypedRecord(string("cryptedhdmaster"), + vchCryptedHDMasterNew, strErrorRet, true) && + dbTmp.WriteTypedRecord(string("cryptedhdchaincode"), + vchCryptedHDChainCodeNew, strErrorRet, true); + if (fOk) + fOk = dbTmp.WriteTypedRecord(string("hdnext"), nHDNextNew, strErrorRet, true); + for (size_t i = 0; fOk && i < vNewPool.size(); i++) + fOk = dbTmp.WriteTypedRecord(make_pair(string("pool"), vNewPool[i].first), + vNewPool[i].second, strErrorRet, true); + + if (!fOk) + { + string strIgnore; + dbTmp.RollbackTransaction(strIgnore); + dbTmp.Close(); + remove(strTempPath.c_str()); + remove((strTempPath + "-wal").c_str()); + remove((strTempPath + "-shm").c_str()); + return false; + } + + if (!dbTmp.CommitTransaction(strErrorRet)) + { + dbTmp.Close(); + remove(strTempPath.c_str()); + remove((strTempPath + "-wal").c_str()); + remove((strTempPath + "-shm").c_str()); + return false; + } + string strCheckpointError; + dbTmp.Checkpoint(strCheckpointError); + dbTmp.Close(); + } + + // The rebuilt wallet is committed and self-contained. Close the live handle + // so the file can be swapped: Windows will not rename a file that still has + // an open handle, and the node exits right after this. + WalletSQLiteRuntimeClose(); + + string strBackupPath = WalletEncryptBackupPathFor("wallet.sqlite"); + if (rename(strWalletPath.c_str(), strBackupPath.c_str()) != 0) + { + strErrorRet = strprintf("could not move the original SQLite wallet to %s", + strBackupPath.c_str()); + remove(strTempPath.c_str()); + return false; + } + // The moved-aside wallet owns any leftover write-ahead log or shared-memory + // sidecar; drop stale ones so they cannot shadow the new wallet. + remove((strWalletPath + "-wal").c_str()); + remove((strWalletPath + "-shm").c_str()); + + if (rename(strTempPath.c_str(), strWalletPath.c_str()) != 0) + { + rename(strBackupPath.c_str(), strWalletPath.c_str()); + strErrorRet = "could not install the encrypted SQLite wallet; the original was restored"; + remove(strTempPath.c_str()); + return false; + } + remove((strTempPath + "-wal").c_str()); + remove((strTempPath + "-shm").c_str()); + + strBackupRet = strBackupPath; + return true; +} + bool EncryptWallet(const string& strPassphrase, string& strBackupRet, string& strErrorRet) { strBackupRet.clear(); @@ -2144,6 +2330,24 @@ bool EncryptWallet(const string& strPassphrase, string& strBackupRet, string& st vNewPool.push_back(make_pair((int64)i + 1, vchPubKey)); } + // Every ciphertext is computed; nothing has touched the store yet. Under + // the SQLite backend, persist by rebuilding wallet.sqlite in one atomic + // transaction and swapping it in. The Berkeley DB path below is unchanged. + if (WalletSQLiteRuntimeActive()) + { + int nCopiedSQLite = 0; + if (!EncryptWalletPersistSQLite(vCryptedKeys, vNewPoolKeys, vNewPool, + kMasterKey, vchCryptedHDMasterNew, + vchCryptedHDChainCodeNew, nHDNextNew, + nCopiedSQLite, strBackupRet, strErrorRet)) + return false; + printf("EncryptWallet() : copied %d public record(s), encrypted %u key(s), " + "rebuilt %d key-pool entry(s) [SQLite]\n", + nCopiedSQLite, (unsigned int)(vCryptedKeys.size() + vNewPoolKeys.size()), + (int)vNewPool.size()); + return true; + } + string strTempFile = strprintf("wallet.encrypting.%lld.dat", GetTime()); string strTempPath = GetAppDir() + "/" + strTempFile; remove(strTempPath.c_str()); @@ -2211,7 +2415,7 @@ bool EncryptWallet(const string& strPassphrase, string& strBackupRet, string& st DBFlush(true); string strWalletPath = GetAppDir() + "/wallet.dat"; - string strBackupPath = WalletEncryptBackupPath(); + string strBackupPath = WalletEncryptBackupPathFor("wallet.dat"); if (rename(strWalletPath.c_str(), strBackupPath.c_str()) != 0) { strErrorRet = strprintf("could not move the original wallet to %s", strBackupPath.c_str()); diff --git a/src/makefile.mingw b/src/makefile.mingw index 14e6b32..1cb47fb 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -112,6 +112,7 @@ tests: test_bip32.exe bitflash.exe ./bitflash.exe -selftest=wallet-sqlite-migration ./bitflash.exe -selftest=wallet-crypto ./bitflash.exe -selftest=wallet-encrypt + ./bitflash.exe -selftest=wallet-sqlite-encrypt ./bitflash.exe -selftest=net-message ./bitflash.exe -selftest=consensus-limits ./bitflash.exe -selftest=pool-stratum diff --git a/src/selftest.cpp b/src/selftest.cpp index 098eed6..fc10947 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -2252,6 +2252,191 @@ static int RunWalletEncryptSelfTest() return nFail == 0 ? 0 : 1; } +// Encrypting a wallet while it runs on the SQLite backend. Unlike the Berkeley +// DB encrypt test above, this drives the real command-line path end to end in +// child processes: build a Berkeley DB wallet, export it to SQLite, then run +// -walletbackend=sqlite -encryptwallet and prove the SQLite wallet came out +// encrypted, unlockable, and with wallet.dat left untouched. +static int RunWalletSQLiteEncryptSelfTest() +{ + fflush(stdout); + printf("wallet-sqlite-encrypt self-test\n"); + + std::string tmp; + std::string cwd; + if (!MakeTempDir(tmp)) + { + printf(" FAIL could not create a temporary data directory\n"); + return 1; + } + if (!GetCurrentDir(cwd) || !SetCurrentDir(tmp)) + { + printf(" FAIL could not move into the temporary data directory\n"); + RemoveTree(tmp); + return 1; + } + + int nFail = 0; + strSetDataDir = tmp; + printf(" temp datadir: %s\n", tmp.c_str()); + + try + { + // Build a normal Berkeley DB wallet with a recovery phrase, a key pool, + // and a couple of public records, then release it. Everything after this + // runs the real command-line paths in child processes. + if (!LoadWallet()) + throw std::runtime_error("LoadWallet failed"); + + string strError; + const string strMnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + nFail += Check(SetHDSeedFromMnemonic(strMnemonic, strError), + "a phrase can be installed before encryption") ? 0 : 1; + TopUpKeyPool(); + + vector vchDefaultPubKey = keyUser.GetPubKey(); + CKey publicLabelKey; + publicLabelKey.MakeNewKey(); + string strPublicLabelAddress = PubKeyToAddress(publicLabelKey.GetPubKey()); + nFail += Check(CWalletDB().WriteName(strPublicLabelAddress, "rewrite-public-name") && + CWalletDB().WriteSetting("rewrite-public-setting", (int64)424242), + "public wallet records can be written before encryption") ? 0 : 1; + + CPrivKey vchDefaultPrivKey; + nFail += Check(GetWalletPrivKey(vchDefaultPubKey, vchDefaultPrivKey, strError), + "the default private key is readable before encryption") ? 0 : 1; + vector vchDefaultPrivBytes(vchDefaultPrivKey.begin(), vchDefaultPrivKey.end()); + vector vchPlainHDMaster = vchHDMaster; + vector vchPlainHDChainCode = vchHDChainCode; + + // Release wallet.dat so a child process can read it for the export. + DBFlush(true); + +#ifdef _WIN32 + string strExe = cwd + "\\bitflash.exe"; +#else + string strExe = cwd + "/bitflash-node"; +#endif + string strSQLitePath = tmp + "/wallet.sqlite"; + string strWalletDatPath = tmp + "/wallet.dat"; + string strRightPassFile = tmp + "/right-pass.txt"; + string strWrongPassFile = tmp + "/wrong-pass.txt"; + nFail += Check(WriteTextFile(strRightPassFile, "btf-test-passphrase\n") && + WriteTextFile(strWrongPassFile, "wrong-passphrase\n"), + "passphrase files can be written for restarted commands") ? 0 : 1; + + // Step 1: export the Berkeley DB wallet to SQLite. + vector vExportArgs; + vExportArgs.push_back("-datadir=" + tmp); + vExportArgs.push_back("-walletsqliteexport=" + strSQLitePath); + vExportArgs.push_back("-nogui"); + int nExportRet = RunBitflashChild(strExe, vExportArgs); + nFail += Check(nExportRet == 0 && FileExists(strSQLitePath.c_str()), + "the wallet exports to a SQLite file") ? 0 : 1; + nFail += Check(FileContainsBytes(strSQLitePath, vchDefaultPrivBytes), + "the exported SQLite wallet holds the plaintext key before encryption") ? 0 : 1; + + // Step 2: encrypt through the real SQLite backend command path. + string strEncryptOut = tmp + "/sqlite-encrypt-out.txt"; + vector vEncryptArgs; + vEncryptArgs.push_back("-datadir=" + tmp); + vEncryptArgs.push_back("-walletbackend=sqlite"); + vEncryptArgs.push_back("-encryptwallet=@" + strRightPassFile); + vEncryptArgs.push_back("-nogui"); + int nEncryptRet = RunBitflashChild(strExe, vEncryptArgs, NULL, &strEncryptOut); + nFail += Check(nEncryptRet == 0, + "the SQLite wallet can be encrypted through -walletbackend=sqlite") ? 0 : 1; + nFail += Check(FileContainsText(strEncryptOut, "Wallet backend: SQLite"), + "encryption took the SQLite persistence path") ? 0 : 1; + nFail += Check(FileContainsText(strEncryptOut, "wallet.sqlite.before-encrypt"), + "the plaintext SQLite wallet is preserved as an explicit backup") ? 0 : 1; + + // The encrypted SQLite wallet must no longer hold the plaintext secrets. + nFail += Check(!FileContainsBytes(strSQLitePath, vchDefaultPrivBytes), + "the encrypted SQLite wallet no longer contains the default private key bytes") ? 0 : 1; + nFail += Check(!FileContainsBytes(strSQLitePath, vchPlainHDMaster), + "the encrypted SQLite wallet no longer contains the HD master key bytes") ? 0 : 1; + nFail += Check(!FileContainsBytes(strSQLitePath, vchPlainHDChainCode), + "the encrypted SQLite wallet no longer contains the HD chain-code bytes") ? 0 : 1; + + // The Berkeley DB wallet.dat is a separate file: the SQLite encrypt path + // must never touch it, so its plaintext key is still exactly there. + nFail += Check(FileContainsBytes(strWalletDatPath, vchDefaultPrivBytes), + "the original Berkeley DB wallet.dat is left untouched by the SQLite encrypt") ? 0 : 1; + + // Step 3: a locked encrypted SQLite wallet reports that phrase coverage + // needs an unlock, exactly as the Berkeley DB path does (exit code 2). + string strLockedAudit = tmp + "/locked-recovery-audit.txt"; + vector vLockedArgs; + vLockedArgs.push_back("-datadir=" + tmp); + vLockedArgs.push_back("-walletbackend=sqlite"); + vLockedArgs.push_back("-recoveryaudit"); + vLockedArgs.push_back("-nogui"); + int nLockedRet = RunBitflashChild(strExe, vLockedArgs, NULL, &strLockedAudit); + nFail += Check(nLockedRet == 2 && + FileContainsText(strLockedAudit, "recovery phrase: encrypted, unlock wallet to audit"), + "a locked encrypted SQLite wallet reports that phrase coverage needs unlock") ? 0 : 1; + + // The wrong passphrase is rejected and reveals nothing. + string strWrongDump = tmp + "/wrong-dump.txt"; + vector vWrongArgs; + vWrongArgs.push_back("-datadir=" + tmp); + vWrongArgs.push_back("-walletbackend=sqlite"); + vWrongArgs.push_back("-walletpassphrase=@" + strWrongPassFile); + vWrongArgs.push_back("-dumpwallet=" + strWrongDump); + vWrongArgs.push_back("-nogui"); + int nWrongRet = RunBitflashChild(strExe, vWrongArgs); + nFail += Check(nWrongRet != 0 && !FileExists(strWrongDump.c_str()), + "a restarted SQLite wallet rejects the wrong passphrase") ? 0 : 1; + + // The right passphrase unlocks and can dump the original key. + string strRightDump = tmp + "/right-dump.txt"; + vector vRightArgs; + vRightArgs.push_back("-datadir=" + tmp); + vRightArgs.push_back("-walletbackend=sqlite"); + vRightArgs.push_back("-walletpassphrase=@" + strRightPassFile); + vRightArgs.push_back("-dumpwallet=" + strRightDump); + vRightArgs.push_back("-nogui"); + int nRightRet = RunBitflashChild(strExe, vRightArgs); + nFail += Check(nRightRet == 0 && FileExists(strRightDump.c_str()), + "a restarted SQLite wallet unlocks with the right passphrase") ? 0 : 1; + nFail += Check(FileContainsText(strRightDump, HexStrLocal(vchDefaultPrivBytes)), + "the unlocked SQLite wallet decrypts and dumps the original key") ? 0 : 1; + + // The recovery phrase survives the encryption. + string strAudit = tmp + "/recovery-audit.txt"; + vector vAuditArgs; + vAuditArgs.push_back("-datadir=" + tmp); + vAuditArgs.push_back("-walletbackend=sqlite"); + vAuditArgs.push_back("-walletpassphrase=@" + strRightPassFile); + vAuditArgs.push_back("-recoveryaudit"); + vAuditArgs.push_back("-nogui"); + int nAuditRet = RunBitflashChild(strExe, vAuditArgs, NULL, &strAudit); + nFail += Check(nAuditRet == 0 && + FileContainsText(strAudit, "recovery phrase: present"), + "the encrypted SQLite wallet still has its recovery phrase") ? 0 : 1; + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + DBFlush(true); + SetCurrentDir(cwd); + RemoveTree(tmp); + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + static int RunWalletPortabilitySelfTest() { fflush(stdout); @@ -2981,6 +3166,8 @@ int RunSelfTest(const std::string& name) return RunWalletCryptoSelfTest(); if (name == "wallet-encrypt") return RunWalletEncryptSelfTest(); + if (name == "wallet-sqlite-encrypt") + return RunWalletSQLiteEncryptSelfTest(); if (name == "wallet-portability") return RunWalletPortabilitySelfTest(); if (name == "net-message") @@ -2997,6 +3184,6 @@ int RunSelfTest(const std::string& name) return RunManagedTorSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, db-env-reopen, wallet-sqlite, wallet-sqlite-migration, wallet-crypto, wallet-encrypt, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, db-env-reopen, wallet-sqlite, wallet-sqlite-migration, wallet-crypto, wallet-encrypt, wallet-sqlite-encrypt, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); return 1; } diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index 4dda8dd..374b2cc 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -1365,6 +1365,11 @@ int CmdEncryptWallet(const std::string& strPassphrase) { AttachTerminal(); + // Capture the backend now: under SQLite, EncryptWallet() closes the runtime + // store to swap the file, so WalletSQLiteRuntimeActive() reads false by the + // time we would decide whether to purge Berkeley DB logs below. + bool fSQLiteBackend = WalletSQLiteRuntimeActive(); + string strBackup; string strError; if (!EncryptWallet(strPassphrase, strBackup, strError)) @@ -1375,6 +1380,8 @@ int CmdEncryptWallet(const std::string& strPassphrase) } printf("Wallet encrypted.\n"); + if (fSQLiteBackend) + printf("Wallet backend: SQLite (wallet.sqlite).\n"); printf("The old unencrypted wallet was moved to:\n"); printf(" %s\n", strBackup.c_str()); printf("That backup still contains private keys in plain text. Move it offline or\n"); @@ -1387,7 +1394,13 @@ int CmdEncryptWallet(const std::string& strPassphrase) // them. Closing the environment and dropping the logs is the last step of // encrypting, not an optimisation -- our own backup advice is to copy the // whole directory, and following it otherwise carries the keys along. - PurgeDbEnvironmentLogs(); + // + // The SQLite backend has no Berkeley DB environment behind the wallet, and + // EncryptWallet() already closed and swapped the SQLite store; there is + // nothing to purge, and calling into the environment here would only open + // one needlessly. + if (!fSQLiteBackend) + PurgeDbEnvironmentLogs(); return 0; } -- cgit v1.2.3 From 440d7f393d74f309d29e1d10ba1f11c41c869226 Mon Sep 17 00:00:00 2001 From: bitflash Date: Tue, 18 Aug 2026 14:33:40 -0300 Subject: wallet: bracket WriteHDMaster in a SQLite runtime transaction The SQLite runtime wrote each record as its own autocommit, so a group that must land together -- hdmaster and hdchaincode, the two halves of an HD seed -- could be left half-written by a failure or crash between them, the incomplete-seed state -walletstoragecheck exists to catch. Berkeley DB was no worse (it is not transactional here either) but no better. Add WalletSQLiteRuntimeBeginTxn/CommitTxn/RollbackTxn -- no-ops that succeed when the SQLite backend is not active, so Berkeley DB keeps its existing per-write behaviour -- and wrap WriteHDMaster in them so the seed lands all-or-nothing under SQLite. The one BDB-atomic wallet rewrite, the encrypt path, is already matched by its own transaction in the SQLite encrypt branch; every other wallet multi-write is ordering-based on both backends by design. wallet-sqlite self-test now proves the bracket: a rolled-back runtime write leaves no record, a committed one persists, and WriteHDMaster lands both halves of the seed. --- src/db.cpp | 34 ++++++++++++++++++++++++++++++++++ src/db.h | 20 ++++++++++++++++++-- src/selftest.cpp | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/db.cpp b/src/db.cpp index 86f6dd7..c16edb4 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -164,6 +164,40 @@ bool WalletSQLiteRuntimeEraseRecord(const vector& vchKey) return pWalletSQLiteRuntime->EraseRecord(vchKey, strError); } +// Transaction bracket for the SQLite runtime. When the SQLite backend is not +// active these are no-ops that succeed, so a caller can wrap a group of related +// writes and Berkeley DB keeps its existing per-write behaviour unchanged while +// SQLite gets all-or-nothing. +bool WalletSQLiteRuntimeBeginTxn() +{ + if (!pWalletSQLiteRuntime) + return true; + string strError; + if (pWalletSQLiteRuntime->BeginTransaction(strError)) + return true; + printf("WalletSQLiteRuntimeBeginTxn() : %s\n", strError.c_str()); + return false; +} + +bool WalletSQLiteRuntimeCommitTxn() +{ + if (!pWalletSQLiteRuntime) + return true; + string strError; + if (pWalletSQLiteRuntime->CommitTransaction(strError)) + return true; + printf("WalletSQLiteRuntimeCommitTxn() : %s\n", strError.c_str()); + return false; +} + +void WalletSQLiteRuntimeRollbackTxn() +{ + if (!pWalletSQLiteRuntime) + return; + string strError; + pWalletSQLiteRuntime->RollbackTransaction(strError); +} + CDB::CDB(const char* pszFile, const char* pszMode, bool fTxn) : pdb(NULL) { diff --git a/src/db.h b/src/db.h index fee5a82..1a272b9 100644 --- a/src/db.h +++ b/src/db.h @@ -365,6 +365,9 @@ bool WalletSQLiteRuntimeWriteRecord(const vector& vchKey, const vector& vchValue, bool fOverwrite); bool WalletSQLiteRuntimeEraseRecord(const vector& vchKey); +bool WalletSQLiteRuntimeBeginTxn(); +bool WalletSQLiteRuntimeCommitTxn(); +void WalletSQLiteRuntimeRollbackTxn(); @@ -531,8 +534,21 @@ public: bool WriteHDMaster(const vector& vchMaster, const vector& vchChainCode) { - return WriteWalletRecord(string("hdmaster"), vchMaster) - && WriteWalletRecord(string("hdchaincode"), vchChainCode); + // hdmaster and hdchaincode are halves of one seed: a wallet with one but + // not the other is an incomplete HD wallet (-walletstoragecheck flags + // exactly that). Under the SQLite runtime write them in one transaction + // so a failure or crash between the two cannot leave that half-state. + // Berkeley DB keeps its existing per-write behaviour -- the bracket is a + // no-op there. + if (!WalletSQLiteRuntimeBeginTxn()) + return false; + if (!WriteWalletRecord(string("hdmaster"), vchMaster) || + !WriteWalletRecord(string("hdchaincode"), vchChainCode)) + { + WalletSQLiteRuntimeRollbackTxn(); + return false; + } + return WalletSQLiteRuntimeCommitTxn(); } bool WriteWalletMinVersion(int nVersion) diff --git a/src/selftest.cpp b/src/selftest.cpp index fc10947..7ad5a53 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1231,6 +1231,43 @@ static int RunWalletSQLiteSelfTest() nFail += Check(!FileExists((strRuntimeDir + "/wallet.dat").c_str()), "active SQLite runtime does not create wallet.dat") ? 0 : 1; + // Transaction bracket: a rolled-back group leaves nothing, a + // committed group persists. This is the all-or-nothing that + // WriteHDMaster relies on so a half-written seed cannot survive + // a failure between its two records. + bool fTxnBegan = WalletSQLiteRuntimeBeginTxn(); + bool fTxnWrote = fTxnBegan && + CWalletDB().WriteSetting("runtime-txn-rollback", (int64)111); + if (fTxnBegan) + WalletSQLiteRuntimeRollbackTxn(); + nFail += Check(fTxnBegan && fTxnWrote, + "a SQLite runtime transaction opens and rolls back") ? 0 : 1; + int64 nRolledBack = 0; + nFail += Check(!CWalletDB("r").ReadSetting("runtime-txn-rollback", + nRolledBack), + "a rolled-back SQLite runtime write leaves no record") ? 0 : 1; + + bool fCommitOk = WalletSQLiteRuntimeBeginTxn() && + CWalletDB().WriteSetting("runtime-txn-commit", (int64)222) && + WalletSQLiteRuntimeCommitTxn(); + int64 nCommitted = 0; + nFail += Check(fCommitOk && + CWalletDB("r").ReadSetting("runtime-txn-commit", + nCommitted) && + nCommitted == 222, + "a committed SQLite runtime write persists") ? 0 : 1; + + // WriteHDMaster brackets its two records in that transaction; + // both halves of the seed must land. + vector vchTxnMaster(32, 0x5a); + vector vchTxnChainCode(32, 0xa5); + vector vchReadMaster, vchReadChainCode; + nFail += Check(CWalletDB().WriteHDMaster(vchTxnMaster, vchTxnChainCode) && + CWalletDB("r").ReadHDMaster(vchReadMaster, vchReadChainCode) && + vchReadMaster == vchTxnMaster && + vchReadChainCode == vchTxnChainCode, + "WriteHDMaster commits both halves of the seed under SQLite") ? 0 : 1; + // Durability: close the backend (checkpoints the WAL), reopen // the same file, and confirm what CWalletDB wrote is still // there. This is the write-survives-a-restart property the real -- cgit v1.2.3 From 84a60275569dca5d1fb12db38ce184caf7487ed6 Mon Sep 17 00:00:00 2001 From: bitflash Date: Tue, 18 Aug 2026 14:44:11 -0300 Subject: wallet: one-shot storage commands honour -walletbackend=sqlite -walletstorageaudit, -walletstoragecheck and -backupwallet all read wallet.dat regardless of the selected backend. Under -walletbackend=sqlite that is wrong, and for backup it is dangerous: it silently backs up the stale plaintext wallet.dat instead of the live wallet.sqlite. - ScanWalletRecords() routes to the active SQLite store instead of a Berkeley DB cursor (it returned an error under the runtime before). This is the single choke point audit and check scan through. - WalletSQLiteRuntimeOpenReadOnly() opens wallet.sqlite for scanning without loading or validating records, so the pre-load diagnostics can inspect a wallet the loader would refuse. main_gui opens it for audit/check when the SQLite backend is selected (and rejects an unknown backend value there too). - BackupWallet() copies wallet.sqlite (checkpoint, then the temp/old/rename dance; a checkpointed SQLite file is self-contained, no lsn_reset). wallet-sqlite-encrypt self-test proves routing on a datadir where the SQLite wallet is encrypted and wallet.dat beside it is still plaintext: audit with the flag reports the encrypted seed, without it reports the plaintext one; the SQLite backup carries no plaintext key and loadchecks as a standalone wallet. --- src/db.cpp | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/db.h | 2 + src/main_gui.cpp | 50 ++++++++++++++++++++++--- src/selftest.cpp | 60 +++++++++++++++++++++++++++++ 4 files changed, 218 insertions(+), 6 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/db.cpp b/src/db.cpp index c16edb4..aafc0f9 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -198,6 +198,38 @@ void WalletSQLiteRuntimeRollbackTxn() pWalletSQLiteRuntime->RollbackTransaction(strError); } +// Open the SQLite wallet read-only as the active runtime. Used by the one-shot +// storage diagnostics, which run before the normal wallet load and must be able +// to inspect a wallet that the loader would refuse -- so this opens the file for +// scanning without loading or validating its records. +bool WalletSQLiteRuntimeOpenReadOnly(const string& strPath, string& strErrorRet) +{ + strErrorRet.clear(); + if (!FileExists(strPath.c_str())) + { + strErrorRet = strprintf("SQLite wallet does not exist: %s", strPath.c_str()); + return false; + } + + WalletSQLiteRuntimeClose(); + std::auto_ptr db(new CWalletDBSQLite()); + if (!db->OpenReadOnly(strPath, strErrorRet)) + return false; + + pWalletSQLiteRuntime = db.release(); + return true; +} + +bool WalletSQLiteRuntimeCheckpoint(string& strErrorRet) +{ + if (!pWalletSQLiteRuntime) + { + strErrorRet = "SQLite wallet backend is not active"; + return false; + } + return pWalletSQLiteRuntime->Checkpoint(strErrorRet); +} + CDB::CDB(const char* pszFile, const char* pszMode, bool fTxn) : pdb(NULL) { @@ -1354,6 +1386,16 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) bool ScanWalletRecords(CWalletRecordVisitor& visitor, string& strErrorRet) { + // Under the SQLite backend there is no Berkeley DB cursor to walk; scan the + // active SQLite store instead. This is the single choke point every "read + // every wallet record" caller goes through (storage audit and check among + // them), so routing it here makes those honour -walletbackend=sqlite. + if (WalletSQLiteRuntimeActive()) + { + strErrorRet.clear(); + return pWalletSQLiteRuntime->ScanRecords(visitor, strErrorRet); + } + class CWalletScanDB : public CWalletDB { public: @@ -1584,11 +1626,81 @@ bool LoadWalletFromSQLiteRuntime(const string& strPath) // this file was written are not spendable from it. Backing up once is not // enough, and that is a property of the wallet format rather than of this // function. +// Back up the active SQLite wallet. The store is checkpointed so the WAL is +// folded into the main file, then the single self-contained wallet.sqlite is +// copied through a temp file and renamed into place (same temp/old/rename dance +// as the Berkeley DB path, for Windows' non-overwriting rename). No lsn_reset: +// a checkpointed SQLite file needs nothing beside it to open. +static bool BackupWalletSQLite(const string& strDest) +{ + string strSrc = GetAppDir() + "/wallet.sqlite"; + if (!FileExists(strSrc.c_str())) + return error("BackupWallet() : %s does not exist\n", strSrc.c_str()); + + string strCheckpointError; + if (!WalletSQLiteRuntimeCheckpoint(strCheckpointError)) + return error("BackupWallet() : checkpoint failed: %s\n", strCheckpointError.c_str()); + + string strTemp = strprintf("%s.tmp.%lld", strDest.c_str(), GetTime()); + for (int i = 1; FileExists(strTemp.c_str()); i++) + strTemp = strprintf("%s.tmp.%lld.%d", strDest.c_str(), GetTime(), i); + string strOld = strprintf("%s.old.%lld", strDest.c_str(), GetTime()); + for (int i = 1; FileExists(strOld.c_str()); i++) + strOld = strprintf("%s.old.%lld.%d", strDest.c_str(), GetTime(), i); + + FILE* pfIn = fopen(strSrc.c_str(), "rb"); + if (!pfIn) + return error("BackupWallet() : cannot read %s\n", strSrc.c_str()); + FILE* pfOut = fopen(strTemp.c_str(), "wb"); + if (!pfOut) + { + fclose(pfIn); + return error("BackupWallet() : cannot write %s\n", strTemp.c_str()); + } + + char buf[65536]; + size_t n; + bool fOk = true; + while ((n = fread(buf, 1, sizeof(buf), pfIn)) > 0) + if (fwrite(buf, 1, n, pfOut) != n) { fOk = false; break; } + if (ferror(pfIn)) + fOk = false; + fclose(pfIn); + if (fclose(pfOut) != 0) + fOk = false; + if (!fOk) + { + remove(strTemp.c_str()); // half a wallet is worse than none + return error("BackupWallet() : copy to %s failed\n", strTemp.c_str()); + } + + bool fHadOldDest = FileExists(strDest.c_str()); + if (fHadOldDest && rename(strDest.c_str(), strOld.c_str()) != 0) + { + remove(strTemp.c_str()); + return error("BackupWallet() : could not move old %s aside\n", strDest.c_str()); + } + if (rename(strTemp.c_str(), strDest.c_str()) != 0) + { + remove(strTemp.c_str()); + if (fHadOldDest) + rename(strOld.c_str(), strDest.c_str()); + return error("BackupWallet() : could not install %s\n", strDest.c_str()); + } + if (fHadOldDest) + remove(strOld.c_str()); + return true; +} + bool BackupWallet(const string& strDest) { if (strDest.empty()) return error("BackupWallet() : no destination given\n"); + // Under the SQLite backend the live wallet is wallet.sqlite, not wallet.dat. + if (WalletSQLiteRuntimeActive()) + return BackupWalletSQLite(strDest); + string strSrc = GetAppDir() + "/wallet.dat"; if (!FileExists(strSrc.c_str())) return error("BackupWallet() : %s does not exist\n", strSrc.c_str()); diff --git a/src/db.h b/src/db.h index 1a272b9..5bc94b1 100644 --- a/src/db.h +++ b/src/db.h @@ -368,6 +368,8 @@ bool WalletSQLiteRuntimeEraseRecord(const vector& vchKey); bool WalletSQLiteRuntimeBeginTxn(); bool WalletSQLiteRuntimeCommitTxn(); void WalletSQLiteRuntimeRollbackTxn(); +bool WalletSQLiteRuntimeOpenReadOnly(const string& strPath, string& strErrorRet); +bool WalletSQLiteRuntimeCheckpoint(string& strErrorRet); diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 4be4995..af6053f 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -524,19 +524,57 @@ int main(int argc, char* argv[]) // explain wallet.dat states that the normal wallet loader may refuse. string strWalletStorageAuditJson = argval2(argc, argv, "/walletstorageauditjson", "-walletstorageauditjson"); - if (arg(argc,argv,"/walletstorageaudit") || - arg(argc,argv,"-walletstorageaudit") || - !strWalletStorageAuditJson.empty()) + bool fDiagWantsAudit = arg(argc,argv,"/walletstorageaudit") || + arg(argc,argv,"-walletstorageaudit") || + !strWalletStorageAuditJson.empty(); + bool fDiagWantsCheck = arg(argc,argv,"/walletstoragecheck") || + arg(argc,argv,"-walletstoragecheck"); + + // These diagnostics normally read wallet.dat. Under -walletbackend=sqlite + // the live wallet is wallet.sqlite, so open it read-only as the active + // store and let the same scan walk the right file. This runs before the + // normal backend resolution further down, so validate the value here too. + string strDiagBackend = argval2(argc, argv, "/walletbackend", "-walletbackend"); + if ((fDiagWantsAudit || fDiagWantsCheck) && !strDiagBackend.empty() && + strDiagBackend != "sqlite" && strDiagBackend != "bdb") + { + AttachTerminal(); + fprintf(stderr, "Unknown wallet backend '%s'. Use -walletbackend=sqlite " + "or -walletbackend=bdb.\n", strDiagBackend.c_str()); + return 1; + } + bool fDiagSQLite = (strDiagBackend == "sqlite") && + (fDiagWantsAudit || fDiagWantsCheck); + if (fDiagSQLite) + { + AttachTerminal(); + string strSQLitePath = GetAppDir() + "/wallet.sqlite"; + string strOpenErr; + if (!WalletSQLiteRuntimeOpenReadOnly(strSQLitePath, strOpenErr)) + { + fprintf(stderr, "Cannot open the SQLite wallet for diagnostics: %s\n", + strOpenErr.c_str()); + return 1; + } + } + + if (fDiagWantsAudit) { int nRet = CmdWalletStorageAudit(strWalletStorageAuditJson); - DBFlush(true); + if (fDiagSQLite) + WalletSQLiteRuntimeClose(); + else + DBFlush(true); return nRet; } - if (arg(argc,argv,"/walletstoragecheck") || arg(argc,argv,"-walletstoragecheck")) + if (fDiagWantsCheck) { int nRet = CmdWalletStorageCheck(); - DBFlush(true); + if (fDiagSQLite) + WalletSQLiteRuntimeClose(); + else + DBFlush(true); return nRet; } diff --git a/src/selftest.cpp b/src/selftest.cpp index 7ad5a53..90a30d2 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -2453,6 +2453,66 @@ static int RunWalletSQLiteEncryptSelfTest() nFail += Check(nAuditRet == 0 && FileContainsText(strAudit, "recovery phrase: present"), "the encrypted SQLite wallet still has its recovery phrase") ? 0 : 1; + + // One-shot storage commands honour -walletbackend. The SQLite wallet is + // encrypted; the wallet.dat beside it is still plaintext. Same datadir, + // opposite answers -> the flag routes the diagnostic to the right file. + string strSqliteAuditJson = tmp + "/sqlite-audit.json"; + vector vSqliteAuditArgs; + vSqliteAuditArgs.push_back("-datadir=" + tmp); + vSqliteAuditArgs.push_back("-walletbackend=sqlite"); + vSqliteAuditArgs.push_back("-walletstorageauditjson=" + strSqliteAuditJson); + vSqliteAuditArgs.push_back("-nogui"); + int nSqliteAuditRet = RunBitflashChild(strExe, vSqliteAuditArgs); + nFail += Check(nSqliteAuditRet == 0 && + FileContainsText(strSqliteAuditJson, "\"encrypted_hd_seed\": \"complete\"") && + FileContainsText(strSqliteAuditJson, "\"plain_hd_seed\": \"none\""), + "-walletstorageaudit under SQLite reads the encrypted wallet.sqlite") ? 0 : 1; + + string strBdbAuditJson = tmp + "/bdb-audit.json"; + vector vBdbAuditArgs; + vBdbAuditArgs.push_back("-datadir=" + tmp); + vBdbAuditArgs.push_back("-walletstorageauditjson=" + strBdbAuditJson); + vBdbAuditArgs.push_back("-nogui"); + int nBdbAuditRet = RunBitflashChild(strExe, vBdbAuditArgs); + nFail += Check(nBdbAuditRet == 0 && + FileContainsText(strBdbAuditJson, "\"plain_hd_seed\": \"complete\"") && + FileContainsText(strBdbAuditJson, "\"encrypted_hd_seed\": \"none\""), + "-walletstorageaudit without the flag still reads the plaintext wallet.dat") ? 0 : 1; + + string strSqliteCheckOut = tmp + "/sqlite-check.txt"; + vector vSqliteCheckArgs; + vSqliteCheckArgs.push_back("-datadir=" + tmp); + vSqliteCheckArgs.push_back("-walletbackend=sqlite"); + vSqliteCheckArgs.push_back("-walletstoragecheck"); + vSqliteCheckArgs.push_back("-nogui"); + int nSqliteCheckRet = RunBitflashChild(strExe, vSqliteCheckArgs, NULL, &strSqliteCheckOut); + nFail += Check(nSqliteCheckRet == 0, + "-walletstoragecheck under SQLite passes on the encrypted wallet.sqlite") ? 0 : 1; + + // Backup under SQLite copies wallet.sqlite, not the plaintext wallet.dat. + // The copy is a standalone SQLite wallet (loadcheck accepts it) and holds + // none of the plaintext key that wallet.dat still contains. + string strBackupDest = tmp + "/backup.sqlite"; + vector vBackupArgs; + vBackupArgs.push_back("-datadir=" + tmp); + vBackupArgs.push_back("-walletbackend=sqlite"); + vBackupArgs.push_back("-backupwallet=" + strBackupDest); + vBackupArgs.push_back("-nogui"); + int nBackupRet = RunBitflashChild(strExe, vBackupArgs); + nFail += Check(nBackupRet == 0 && FileExists(strBackupDest.c_str()), + "-backupwallet under SQLite writes a backup file") ? 0 : 1; + nFail += Check(!FileContainsBytes(strBackupDest, vchDefaultPrivBytes), + "the SQLite backup holds no plaintext key (it copied wallet.sqlite, not wallet.dat)") ? 0 : 1; + + string strBackupLoadCheck = tmp + "/backup-loadcheck.txt"; + vector vLoadCheckArgs; + vLoadCheckArgs.push_back("-datadir=" + tmp); + vLoadCheckArgs.push_back("-walletsqliteloadcheck=" + strBackupDest); + vLoadCheckArgs.push_back("-nogui"); + int nBackupLoadRet = RunBitflashChild(strExe, vLoadCheckArgs, NULL, &strBackupLoadCheck); + nFail += Check(nBackupLoadRet == 0, + "the SQLite backup is a valid standalone SQLite wallet") ? 0 : 1; } catch (const std::exception& e) { -- cgit v1.2.3 From 3f94ec0649f935d00de49ad7b55026317dc64a27 Mon Sep 17 00:00:00 2001 From: bitflash Date: Tue, 18 Aug 2026 16:14:19 -0300 Subject: wallet: make SQLite the default backend for new wallets A fresh install with no wallet.dat now starts on the SQLite backend and creates wallet.sqlite, instead of a Berkeley DB wallet.dat. The choice is recorded in a plain-text /wallet-backend marker so it survives a restart without a command-line flag. Backend resolution when no -walletbackend is given: an existing wallet.sqlite (or a "sqlite" marker) stays on SQLite; an existing wallet.dat stays on Berkeley DB -- nothing is converted behind the user; a fresh datadir starts on SQLite. An explicit -walletbackend=bdb or a "bdb" marker forces Berkeley DB and overrides the default. Adds CreateNewSQLiteWallet() (WalletSQLiteRuntimeOpen gains fMustExist so the create path can make a new file) which mints the first default key straight into the SQLite store via the same FinishLoadedWallet() call the Berkeley DB create path uses, then cleans up on failure. Marker read/write helpers are plain text, hand-editable. New wallet-backend-default self-test proves the resolution in child processes (fresh->SQLite+marker, existing wallet.dat->BDB, "bdb" marker->BDB, explicit flag->wallet.dat). The three wallet.dat-specific suites now ask for -walletbackend=bdb explicitly, since a flagless fresh datadir is SQLite now. All wallet suites pass. --- src/db.cpp | 91 +++++++++++++++++++++++++++++++++++- src/db.h | 5 +- src/main_gui.cpp | 80 ++++++++++++++++++++++++++++--- src/makefile.mingw | 1 + src/selftest.cpp | 135 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 301 insertions(+), 11 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/db.cpp b/src/db.cpp index aafc0f9..949865f 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -101,10 +101,13 @@ bool WalletSQLiteRuntimeActive() return pWalletSQLiteRuntime != NULL; } -bool WalletSQLiteRuntimeOpen(const string& strPath, string& strErrorRet) +bool WalletSQLiteRuntimeOpen(const string& strPath, string& strErrorRet, bool fMustExist) { strErrorRet.clear(); - if (!FileExists(strPath.c_str())) + // Callers that load an existing wallet want the clear "does not exist" + // message rather than silently creating a blank one; the create path passes + // fMustExist=false so Open() (SQLITE_OPEN_CREATE) can make a fresh file. + if (fMustExist && !FileExists(strPath.c_str())) { strErrorRet = strprintf("SQLite wallet does not exist: %s", strPath.c_str()); @@ -230,6 +233,47 @@ bool WalletSQLiteRuntimeCheckpoint(string& strErrorRet) return pWalletSQLiteRuntime->Checkpoint(strErrorRet); } +// The persistent record of which backend this datadir uses, so the choice +// survives a restart without a command-line flag. A plain text file holding +// "sqlite" or "bdb" -- visible and hand-editable on purpose, no hidden state. +static string WalletBackendMarkerPath() +{ + return GetAppDir() + "/wallet-backend"; +} + +string ReadWalletBackendMarker() +{ + FILE* pf = fopen(WalletBackendMarkerPath().c_str(), "rb"); + if (!pf) + return ""; + char buf[16] = {0}; + size_t n = fread(buf, 1, sizeof(buf) - 1, pf); + fclose(pf); + string s(buf, n); + while (!s.empty() && isspace((unsigned char)s[s.size() - 1])) + s.erase(s.size() - 1); + while (!s.empty() && isspace((unsigned char)s[0])) + s.erase(0, 1); + if (s == "sqlite" || s == "bdb") + return s; + return ""; +} + +bool WriteWalletBackendMarker(const string& strBackend) +{ + if (strBackend != "sqlite" && strBackend != "bdb") + return false; + FILE* pf = fopen(WalletBackendMarkerPath().c_str(), "wb"); + if (!pf) + return false; + bool fOk = fwrite(strBackend.c_str(), 1, strBackend.size(), pf) == strBackend.size(); + if (fputc('\n', pf) == EOF) + fOk = false; + if (fclose(pf) != 0) + fOk = false; + return fOk; +} + CDB::CDB(const char* pszFile, const char* pszMode, bool fTxn) : pdb(NULL) { @@ -1609,6 +1653,49 @@ bool LoadWalletFromSQLiteRuntime(const string& strPath) return true; } +// Create a brand-new wallet directly in the SQLite backend. For a fresh install +// where SQLite is the default there is no wallet.dat to export from, so open a +// new wallet.sqlite (Open() creates the file and its schema) and let +// FinishLoadedWallet mint the first default key straight into it -- the same +// call the Berkeley DB create path uses, so a new wallet is identical either +// way. Refuses to touch an existing file, and cleans up on failure so a broken +// half-initialised wallet.sqlite is never left for the next start to load. +bool CreateNewSQLiteWallet(const string& strPath) +{ + strWalletLoadError.clear(); + + if (FileExists(strPath.c_str())) + { + strWalletLoadError = strprintf("SQLite wallet already exists: %s", + strPath.c_str()); + printf("CreateNewSQLiteWallet: %s\n", strWalletLoadError.c_str()); + return false; + } + + string strError; + if (!WalletSQLiteRuntimeOpen(strPath, strError, false)) + { + strWalletLoadError = strError; + printf("CreateNewSQLiteWallet: cannot create SQLite wallet: %s\n", + strError.c_str()); + return false; + } + + vector vchNoDefaultKey; + if (!FinishLoadedWallet(vchNoDefaultKey, true)) + { + WalletSQLiteRuntimeClose(); + remove(strPath.c_str()); + remove((strPath + "-wal").c_str()); + remove((strPath + "-shm").c_str()); + if (strWalletLoadError.empty()) + strWalletLoadError = "could not initialise the new SQLite wallet"; + return false; + } + + return true; +} + // Write a copy of wallet.dat that can be opened anywhere. // diff --git a/src/db.h b/src/db.h index 5bc94b1..c50065b 100644 --- a/src/db.h +++ b/src/db.h @@ -357,7 +357,7 @@ private: // peer discovery is entirely Nostr/.btf-based now. bool WalletSQLiteRuntimeActive(); -bool WalletSQLiteRuntimeOpen(const string& strPath, string& strErrorRet); +bool WalletSQLiteRuntimeOpen(const string& strPath, string& strErrorRet, bool fMustExist=true); void WalletSQLiteRuntimeClose(); bool WalletSQLiteRuntimeReadRecord(const vector& vchKey, vector& vchValueRet); @@ -370,6 +370,9 @@ bool WalletSQLiteRuntimeCommitTxn(); void WalletSQLiteRuntimeRollbackTxn(); bool WalletSQLiteRuntimeOpenReadOnly(const string& strPath, string& strErrorRet); bool WalletSQLiteRuntimeCheckpoint(string& strErrorRet); +string ReadWalletBackendMarker(); +bool WriteWalletBackendMarker(const string& strBackend); +bool CreateNewSQLiteWallet(const string& strPath); diff --git a/src/main_gui.cpp b/src/main_gui.cpp index af6053f..6cd8b75 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -743,14 +743,80 @@ int main(int argc, char* argv[]) DBFlush(true); return 0; } - else if (!LoadWallet()) + else if (strWalletBackend == "bdb") { - // LoadWallet() explains itself into strWalletLoadError when it - // knows why -- an unsupported wallet format, for one, which is the - // case this whole path exists to make visible. - FatalStartupError(fHeadlessStartup, "Cannot open wallet.dat.", - strWalletLoadError); - return 1; + // Explicit -walletbackend=bdb overrides the marker and the default: + // force Berkeley DB (LoadWallet creates wallet.dat if none exists). + if (!LoadWallet()) + { + FatalStartupError(fHeadlessStartup, "Cannot open wallet.dat.", + strWalletLoadError); + return 1; + } + } + else + { + // No explicit -walletbackend on the command line. Resolve it from + // the persistent marker, then defaults: an existing wallet.sqlite + // stays on SQLite, an existing wallet.dat stays on Berkeley DB (the + // GUI offers to convert), and a fresh install starts on SQLite -- + // the new default. wallet.dat is never touched by any of this. + string strSQLitePath = GetAppDir() + "/wallet.sqlite"; + bool fSQLiteExists = FileExists(strSQLitePath.c_str()); + bool fWalletDatExists = FileExists((GetAppDir() + "/wallet.dat").c_str()); + string strMarker = ReadWalletBackendMarker(); + + bool fUseSQLite = false; + bool fCreateSQLite = false; + if (strMarker == "sqlite") + { + if (fSQLiteExists) fUseSQLite = true; + else if (fWalletDatExists) fUseSQLite = false; // marker stale; real wallet is BDB + else { fUseSQLite = true; fCreateSQLite = true; } + } + else if (strMarker == "bdb") + { + fUseSQLite = false; + } + else // no marker recorded yet + { + if (fSQLiteExists && !fWalletDatExists) fUseSQLite = true; + else if (fWalletDatExists) fUseSQLite = false; + else { fUseSQLite = true; fCreateSQLite = true; } + } + + if (fUseSQLite) + { + AttachTerminal(); + bool fOk = fCreateSQLite + ? CreateNewSQLiteWallet(strSQLitePath) + : LoadWalletFromSQLiteRuntime(strSQLitePath); + if (!fOk) + { + FatalStartupError(fHeadlessStartup, + fCreateSQLite ? "Cannot create the SQLite wallet." + : "Cannot open the SQLite wallet.", + strWalletLoadError); + return 1; + } + // Record the choice so it is stable from here on. + if (ReadWalletBackendMarker() != "sqlite") + WriteWalletBackendMarker("sqlite"); + fprintf(stderr, "Wallet backend: SQLite\n %s\n%s", + strSQLitePath.c_str(), + fCreateSQLite ? "A new wallet was created here.\n" : ""); + fflush(stderr); + // Fall through and run the node; CWalletDB routes to this file. + } + else if (!LoadWallet()) + { + // LoadWallet() explains itself into strWalletLoadError when it + // knows why -- an unsupported wallet format, for one, which is + // the case this whole path exists to make visible. + FatalStartupError(fHeadlessStartup, "Cannot open wallet.dat.", + strWalletLoadError); + return 1; + } } // After the block index, so there is a chain to compare the wallet diff --git a/src/makefile.mingw b/src/makefile.mingw index 1cb47fb..06c9211 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -113,6 +113,7 @@ tests: test_bip32.exe bitflash.exe ./bitflash.exe -selftest=wallet-crypto ./bitflash.exe -selftest=wallet-encrypt ./bitflash.exe -selftest=wallet-sqlite-encrypt + ./bitflash.exe -selftest=wallet-backend-default ./bitflash.exe -selftest=net-message ./bitflash.exe -selftest=consensus-limits ./bitflash.exe -selftest=pool-stratum diff --git a/src/selftest.cpp b/src/selftest.cpp index 90a30d2..1960003 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -911,6 +911,7 @@ static int RunWalletStorageSanitySelfTest() vCreateArgs.push_back("-datadir=" + strScenarioDir); vCreateArgs.push_back("-nomanagedtor"); vCreateArgs.push_back("-nogui"); + vCreateArgs.push_back("-walletbackend=bdb"); // this suite is about wallet.dat vCreateArgs.push_back("-newphrase"); int nCreateRet = RunBitflashChild(strExe, vCreateArgs, NULL, &strCreateOut); nFail += Check(nCreateRet == 0 && @@ -1474,6 +1475,7 @@ static int RunWalletSQLiteMigrationSelfTest() vCreateArgs.push_back("-datadir=" + strWalletDir); vCreateArgs.push_back("-nomanagedtor"); vCreateArgs.push_back("-nogui"); + vCreateArgs.push_back("-walletbackend=bdb"); // migrate FROM a Berkeley DB wallet.dat vCreateArgs.push_back("-newphrase"); int nCreateRet = RunBitflashChild(strExe, vCreateArgs, NULL, &strCreateOut); nFail += Check(nCreateRet == 0 && @@ -2534,6 +2536,134 @@ static int RunWalletSQLiteEncryptSelfTest() return nFail == 0 ? 0 : 1; } +// Backend resolution when no -walletbackend flag is given: a fresh install +// starts on SQLite (the new default), an existing wallet.dat stays on Berkeley +// DB, the persistent marker overrides the default, and an explicit flag +// overrides everything. Each scenario runs a real child process. +static int RunWalletBackendDefaultSelfTest() +{ + fflush(stdout); + printf("wallet-backend-default self-test\n"); + + std::string tmp, cwd; + if (!MakeTempDir(tmp)) + { + printf(" FAIL could not create a temporary data directory\n"); + return 1; + } + if (!GetCurrentDir(cwd) || !SetCurrentDir(tmp)) + { + printf(" FAIL could not move into the temporary data directory\n"); + RemoveTree(tmp); + return 1; + } + + int nFail = 0; + strSetDataDir = tmp; + printf(" temp datadir: %s\n", tmp.c_str()); + +#ifdef _WIN32 + string strExe = cwd + "\\bitflash.exe"; +#else + string strExe = cwd + "/bitflash-node"; +#endif + + try + { + // 1. Fresh datadir, no flag -> new wallet.sqlite, marker "sqlite", no wallet.dat. + string d1 = tmp + "/fresh"; + nFail += Check(MakeDirLocal(d1), "fresh scenario dir can be created") ? 0 : 1; + vector a1; + a1.push_back("-datadir=" + d1); + a1.push_back("-nomanagedtor"); + a1.push_back("-nogui"); + a1.push_back("-newaddress"); + int r1 = RunBitflashChild(strExe, a1); + nFail += Check(r1 == 0 && + FileExists((d1 + "/wallet.sqlite").c_str()) && + !FileExists((d1 + "/wallet.dat").c_str()) && + FileContainsText(d1 + "/wallet-backend", "sqlite"), + "a fresh install starts on SQLite (new default) and records the marker") ? 0 : 1; + + // 1b. Second run reuses it, still SQLite, still no wallet.dat. + int r1b = RunBitflashChild(strExe, a1); + nFail += Check(r1b == 0 && + FileExists((d1 + "/wallet.sqlite").c_str()) && + !FileExists((d1 + "/wallet.dat").c_str()), + "the recorded SQLite choice survives a restart") ? 0 : 1; + + // 2. Existing wallet.dat, no flag -> stays on Berkeley DB. + string d2 = tmp + "/existing-bdb"; + nFail += Check(MakeDirLocal(d2), "existing-bdb scenario dir can be created") ? 0 : 1; + vector a2c; + a2c.push_back("-datadir=" + d2); + a2c.push_back("-nomanagedtor"); + a2c.push_back("-nogui"); + a2c.push_back("-walletbackend=bdb"); + a2c.push_back("-newphrase"); + RunBitflashChild(strExe, a2c); + vector a2; + a2.push_back("-datadir=" + d2); + a2.push_back("-nomanagedtor"); + a2.push_back("-nogui"); + a2.push_back("-newaddress"); + int r2 = RunBitflashChild(strExe, a2); + nFail += Check(r2 == 0 && + FileExists((d2 + "/wallet.dat").c_str()) && + !FileExists((d2 + "/wallet.sqlite").c_str()), + "an existing wallet.dat keeps a flagless start on Berkeley DB") ? 0 : 1; + + // 3. Marker "bdb" forces Berkeley DB even on a fresh datadir. + string d3 = tmp + "/marker-bdb"; + nFail += Check(MakeDirLocal(d3), "marker-bdb scenario dir can be created") ? 0 : 1; + nFail += Check(WriteTextFile(d3 + "/wallet-backend", "bdb\n"), + "a bdb marker can be written") ? 0 : 1; + vector a3; + a3.push_back("-datadir=" + d3); + a3.push_back("-nomanagedtor"); + a3.push_back("-nogui"); + a3.push_back("-newaddress"); + int r3 = RunBitflashChild(strExe, a3); + nFail += Check(r3 == 0 && + FileExists((d3 + "/wallet.dat").c_str()) && + !FileExists((d3 + "/wallet.sqlite").c_str()), + "a bdb marker forces Berkeley DB on a fresh datadir") ? 0 : 1; + + // 4. Explicit -walletbackend=bdb on a fresh datadir -> wallet.dat. + string d4 = tmp + "/explicit-bdb"; + nFail += Check(MakeDirLocal(d4), "explicit-bdb scenario dir can be created") ? 0 : 1; + vector a4; + a4.push_back("-datadir=" + d4); + a4.push_back("-nomanagedtor"); + a4.push_back("-nogui"); + a4.push_back("-walletbackend=bdb"); + a4.push_back("-newaddress"); + int r4 = RunBitflashChild(strExe, a4); + nFail += Check(r4 == 0 && + FileExists((d4 + "/wallet.dat").c_str()) && + !FileExists((d4 + "/wallet.sqlite").c_str()), + "an explicit -walletbackend=bdb creates wallet.dat on a fresh datadir") ? 0 : 1; + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + DBFlush(true); + SetCurrentDir(cwd); + RemoveTree(tmp); + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + static int RunWalletPortabilitySelfTest() { fflush(stdout); @@ -2576,6 +2706,7 @@ static int RunWalletPortabilitySelfTest() vCreateArgs.push_back("-datadir=" + strOriginal); vCreateArgs.push_back("-nomanagedtor"); vCreateArgs.push_back("-nogui"); + vCreateArgs.push_back("-walletbackend=bdb"); // this suite is about wallet.dat portability vCreateArgs.push_back("-newphrase"); int nCreateRet = RunBitflashChild(strExe, vCreateArgs, NULL, &strCreateOut); nFail += Check(nCreateRet == 0 && @@ -3265,6 +3396,8 @@ int RunSelfTest(const std::string& name) return RunWalletEncryptSelfTest(); if (name == "wallet-sqlite-encrypt") return RunWalletSQLiteEncryptSelfTest(); + if (name == "wallet-backend-default") + return RunWalletBackendDefaultSelfTest(); if (name == "wallet-portability") return RunWalletPortabilitySelfTest(); if (name == "net-message") @@ -3281,6 +3414,6 @@ int RunSelfTest(const std::string& name) return RunManagedTorSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, db-env-reopen, wallet-sqlite, wallet-sqlite-migration, wallet-crypto, wallet-encrypt, wallet-sqlite-encrypt, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, db-env-reopen, wallet-sqlite, wallet-sqlite-migration, wallet-crypto, wallet-encrypt, wallet-sqlite-encrypt, wallet-backend-default, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); return 1; } -- cgit v1.2.3 From 477d57b9a01102b6f79ad9b10b2dcfd280faa1cb Mon Sep 17 00:00:00 2001 From: bitflash Date: Tue, 18 Aug 2026 16:26:34 -0300 Subject: gui: offer to convert a Berkeley DB wallet to SQLite on first run A first-run dialog for an existing wallet.dat with no backend choice yet: "Convert to SQLite" exports the wallet and records the sqlite marker so the next start opens it; "Keep Berkeley DB" records that choice so the offer does not return; "Decide later" leaves no marker and asks again. wallet.dat is never touched, and the change takes effect on restart -- matching the restart-to-apply model. The conversion itself is DoConvertWalletToSQLite(), a headless-testable function the button calls: it reuses the export path (extracted as ExportActiveWalletToSQLite, shared with -walletsqliteexport, which keeps its own specific messages), refuses if already on SQLite or if a wallet.sqlite exists, and writes the marker only after a verified export. New wallet-convert self-test proves the action end to end: build a Berkeley DB wallet, convert, and confirm wallet.sqlite + the marker are written, wallet.dat is left untouched, a second convert refuses, and a flagless restart opens the converted SQLite wallet with its phrase intact. All wallet suites pass. The modal itself is thin UI over this tested action. --- src/gui.cpp | 90 ++++++++++++++++++++++++++++++++++++++++ src/makefile.mingw | 1 + src/selftest.cpp | 108 +++++++++++++++++++++++++++++++++++++++++++++++- src/walletcmd.cpp | 119 +++++++++++++++++++++++++++++++++++++---------------- src/walletcmd.h | 9 ++++ 5 files changed, 290 insertions(+), 37 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/gui.cpp b/src/gui.cpp index 8dcb854..89a7ab6 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -1391,6 +1391,84 @@ static void DrawAboutDialog() ImGui::End(); } +// First-run offer to move a Berkeley DB wallet to the SQLite backend. Only the +// button actions touch anything: "Convert" exports wallet.dat to wallet.sqlite +// and records the backend marker (wallet.dat is left untouched, so a restart +// picks up SQLite while the old file stays as a fallback); "Keep Berkeley DB" +// just records that choice so the offer does not return; "Decide later" leaves +// no marker and asks again next start. +static bool g_showBackendWizard = false; +static std::string g_wizardStatus; +static bool g_wizardConverted = false; + +static void DrawBackendWizardDialog() +{ + if (!g_showBackendWizard) return; + ImGui::SetNextWindowSize(ImVec2(560.0f, 0.0f), ImGuiCond_Always); + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), + ImGuiCond_Always, ImVec2(0.5f, 0.5f)); + if (ImGui::Begin("Wallet Storage", &g_showBackendWizard, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse)) + { + if (g_wizardConverted) + { + ImGui::TextColored(ImVec4(0.35f, 1.0f, 0.45f, 1.0f), "Converted to SQLite."); + ImGui::TextWrapped("%s", g_wizardStatus.c_str()); + ImGui::Spacing(); + ImGui::TextWrapped( + "Restart Bitflash to start using the SQLite wallet. Your Berkeley " + "DB wallet.dat is kept untouched as a fallback."); + ImGui::Spacing(); + if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) + g_showBackendWizard = false; + } + else + { + ImGui::TextWrapped( + "Bitflash can store your wallet in a new SQLite format: a single " + "file that is easier to back up and does not depend on the " + "database/ directory beside it."); + ImGui::Spacing(); + ImGui::TextWrapped( + "Your current Berkeley DB wallet (wallet.dat) is kept exactly as it " + "is -- nothing is deleted, and you can go back to it at any time. " + "The change takes effect after you restart."); + ImGui::Spacing(); + if (ImGui::Button("Convert to SQLite", ImVec2(180.0f, 0.0f))) + { + int nCopied = 0; + std::string strError; + if (DoConvertWalletToSQLite(nCopied, strError)) + { + g_wizardStatus = + strprintf("%d record(s) copied to wallet.sqlite.", nCopied); + g_wizardConverted = true; + } + else + g_wizardStatus = "Conversion failed: " + strError; + } + ImGui::SameLine(); + if (ImGui::Button("Keep Berkeley DB", ImVec2(160.0f, 0.0f))) + { + WriteWalletBackendMarker("bdb"); + g_showBackendWizard = false; + } + ImGui::SameLine(); + if (ImGui::Button("Decide later", ImVec2(120.0f, 0.0f))) + g_showBackendWizard = false; + + if (!g_wizardStatus.empty()) + { + ImGui::Spacing(); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.45f, 0.35f, 1.0f)); + ImGui::TextWrapped("%s", g_wizardStatus.c_str()); + ImGui::PopStyleColor(); + } + } + } + ImGui::End(); +} + // --------------------------------------------------------------------------- // Entry point // --------------------------------------------------------------------------- @@ -1457,6 +1535,17 @@ int RunGUI(int argc, char* argv[]) g_mineRadio = nMineMode; strncpy(g_participantPool, strParticipantPool.c_str(), sizeof(g_participantPool)-1); + // First run on a Berkeley DB wallet with no recorded choice yet: offer to + // convert to SQLite. Never when already on SQLite, once wallet.sqlite exists, + // or once a choice has been recorded in the backend marker. + if (!WalletSQLiteRuntimeActive() && + FileExists((GetAppDir() + "/wallet.dat").c_str()) && + !FileExists((GetAppDir() + "/wallet.sqlite").c_str()) && + ReadWalletBackendMarker().empty()) + { + g_showBackendWizard = true; + } + int frame = 0; while (!glfwWindowShouldClose(window) && !fShutdown) { @@ -1480,6 +1569,7 @@ int RunGUI(int argc, char* argv[]) DrawRestorePhraseDialog(); DrawDiagnosticsDialog(); DrawAboutDialog(); + DrawBackendWizardDialog(); DrawStatusBar(); ImGui::Render(); diff --git a/src/makefile.mingw b/src/makefile.mingw index 06c9211..55f186b 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -114,6 +114,7 @@ tests: test_bip32.exe bitflash.exe ./bitflash.exe -selftest=wallet-encrypt ./bitflash.exe -selftest=wallet-sqlite-encrypt ./bitflash.exe -selftest=wallet-backend-default + ./bitflash.exe -selftest=wallet-convert ./bitflash.exe -selftest=net-message ./bitflash.exe -selftest=consensus-limits ./bitflash.exe -selftest=pool-stratum diff --git a/src/selftest.cpp b/src/selftest.cpp index 1960003..a9c73fe 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -2664,6 +2664,110 @@ static int RunWalletBackendDefaultSelfTest() return nFail == 0 ? 0 : 1; } +// The wizard's conversion action, headless: build a Berkeley DB wallet, run +// DoConvertWalletToSQLite() (what the GUI button calls), and prove it produced a +// usable wallet.sqlite + backend marker while leaving wallet.dat untouched, then +// that a flagless restart opens the converted SQLite wallet. +static int RunWalletConvertSelfTest() +{ + fflush(stdout); + printf("wallet-convert self-test\n"); + + std::string tmp, cwd; + if (!MakeTempDir(tmp)) + { + printf(" FAIL could not create a temporary data directory\n"); + return 1; + } + if (!GetCurrentDir(cwd) || !SetCurrentDir(tmp)) + { + printf(" FAIL could not move into the temporary data directory\n"); + RemoveTree(tmp); + return 1; + } + + int nFail = 0; + strSetDataDir = tmp; + printf(" temp datadir: %s\n", tmp.c_str()); + +#ifdef _WIN32 + string strExe = cwd + "\\bitflash.exe"; +#else + string strExe = cwd + "/bitflash-node"; +#endif + + try + { + if (!LoadWallet()) + throw std::runtime_error("LoadWallet failed"); + + string strError; + nFail += Check(SetHDSeedFromMnemonic( + "abandon abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon about", strError), + "a phrase can be installed before conversion") ? 0 : 1; + TopUpKeyPool(); + + vector vchDefaultPubKey = keyUser.GetPubKey(); + CPrivKey vchDefaultPrivKey; + nFail += Check(GetWalletPrivKey(vchDefaultPubKey, vchDefaultPrivKey, strError), + "the default private key is readable before conversion") ? 0 : 1; + vector vchDefaultPrivBytes(vchDefaultPrivKey.begin(), + vchDefaultPrivKey.end()); + + // The conversion action the GUI button invokes. + int nCopied = 0; + string strConvertError; + bool fConverted = DoConvertWalletToSQLite(nCopied, strConvertError); + nFail += Check(fConverted && nCopied > 0, + "DoConvertWalletToSQLite exports the wallet") ? 0 : 1; + nFail += Check(FileExists((tmp + "/wallet.sqlite").c_str()) && + FileContainsText(tmp + "/wallet-backend", "sqlite"), + "conversion writes wallet.sqlite and records the sqlite marker") ? 0 : 1; + nFail += Check(FileContainsBytes(tmp + "/wallet.dat", vchDefaultPrivBytes), + "wallet.dat is left untouched by the conversion") ? 0 : 1; + + // Idempotence guard: a second conversion refuses rather than overwrite. + int nCopied2 = 0; + string strSecondError; + nFail += Check(!DoConvertWalletToSQLite(nCopied2, strSecondError), + "a second conversion refuses to overwrite wallet.sqlite") ? 0 : 1; + + DBFlush(true); + + // A flagless restart now opens the converted SQLite wallet (the marker + // routes it) and finds the same recovery phrase. + string strAudit = tmp + "/converted-recovery-audit.txt"; + vector vAuditArgs; + vAuditArgs.push_back("-datadir=" + tmp); + vAuditArgs.push_back("-nomanagedtor"); + vAuditArgs.push_back("-nogui"); + vAuditArgs.push_back("-recoveryaudit"); + int nAuditRet = RunBitflashChild(strExe, vAuditArgs, NULL, &strAudit); + nFail += Check(nAuditRet == 0 && + FileContainsText(strAudit, "recovery phrase: present"), + "a flagless restart opens the converted SQLite wallet") ? 0 : 1; + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + DBFlush(true); + SetCurrentDir(cwd); + RemoveTree(tmp); + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + static int RunWalletPortabilitySelfTest() { fflush(stdout); @@ -3398,6 +3502,8 @@ int RunSelfTest(const std::string& name) return RunWalletSQLiteEncryptSelfTest(); if (name == "wallet-backend-default") return RunWalletBackendDefaultSelfTest(); + if (name == "wallet-convert") + return RunWalletConvertSelfTest(); if (name == "wallet-portability") return RunWalletPortabilitySelfTest(); if (name == "net-message") @@ -3414,6 +3520,6 @@ int RunSelfTest(const std::string& name) return RunManagedTorSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, db-env-reopen, wallet-sqlite, wallet-sqlite-migration, wallet-crypto, wallet-encrypt, wallet-sqlite-encrypt, wallet-backend-default, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, db-env-reopen, wallet-sqlite, wallet-sqlite-migration, wallet-crypto, wallet-encrypt, wallet-sqlite-encrypt, wallet-backend-default, wallet-convert, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); return 1; } diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index 374b2cc..1f1fd27 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -1070,79 +1070,126 @@ public: } // namespace -int CmdWalletSQLiteExport(const std::string& strDest) +// Export the currently-loaded wallet to a SQLite file. Shared by the +// -walletsqliteexport command and the GUI conversion wizard: opens the +// destination, copies every record inside one transaction, checkpoints, and +// verifies the stored count matches. Returns false with strErrorRet set on any +// failure and leaves no partial file behind; the source wallet is only read. +bool ExportActiveWalletToSQLite(const std::string& strDest, int& nCopiedRet, + std::string& strErrorRet) { - AttachTerminal(); - + nCopiedRet = 0; if (strDest.empty()) { - fprintf(stderr, "Missing SQLite wallet export path.\n"); - return 1; + strErrorRet = "no SQLite export path was given"; + return false; } if (FileExists(strDest.c_str())) { - fprintf(stderr, "Refusing to overwrite existing SQLite wallet export: %s\n", - strDest.c_str()); - return 1; + strErrorRet = strprintf("refusing to overwrite existing file: %s", strDest.c_str()); + return false; } - string strError; CWalletDBSQLite db; - if (!db.Open(strDest, strError)) + if (!db.Open(strDest, strErrorRet)) { RemoveSQLiteExportFiles(strDest); - fprintf(stderr, "Cannot open SQLite wallet export: %s\n", strError.c_str()); - return 1; + return false; } - if (!db.BeginTransaction(strError)) + if (!db.BeginTransaction(strErrorRet)) { db.Close(); RemoveSQLiteExportFiles(strDest); - fprintf(stderr, "Cannot begin SQLite wallet export: %s\n", strError.c_str()); - return 1; + return false; } - int nCopied = 0; - CWalletSQLiteExportVisitor visitor(db, nCopied); - if (!ScanWalletRecords(visitor, strError)) + CWalletSQLiteExportVisitor visitor(db, nCopiedRet); + if (!ScanWalletRecords(visitor, strErrorRet)) { string strRollbackError; db.RollbackTransaction(strRollbackError); db.Close(); RemoveSQLiteExportFiles(strDest); - fprintf(stderr, "Cannot export wallet.dat to SQLite: %s\n", strError.c_str()); - return 1; + return false; } - if (!db.CommitTransaction(strError)) + if (!db.CommitTransaction(strErrorRet) || !db.Checkpoint(strErrorRet)) { db.Close(); RemoveSQLiteExportFiles(strDest); - fprintf(stderr, "Cannot commit SQLite wallet export: %s\n", strError.c_str()); - return 1; + return false; } - if (!db.Checkpoint(strError)) + + int nCount = 0; + if (!db.CountRecords(nCount, strErrorRet)) { db.Close(); RemoveSQLiteExportFiles(strDest); - fprintf(stderr, "Cannot checkpoint SQLite wallet export: %s\n", strError.c_str()); - return 1; + return false; } - - int nCount = 0; - if (!db.CountRecords(nCount, strError)) + if (nCount != nCopiedRet) { + strErrorRet = strprintf("export count mismatch: copied %d, stored %d", + nCopiedRet, nCount); db.Close(); RemoveSQLiteExportFiles(strDest); - fprintf(stderr, "Cannot verify SQLite wallet export count: %s\n", - strError.c_str()); + return false; + } + db.Close(); + return true; +} + +// Convert the running Berkeley DB wallet to SQLite for the GUI wizard: export +// wallet.dat to /wallet.sqlite, then record the backend marker so the +// next start opens SQLite. wallet.dat is never modified -- it stays as the +// fallback. Refuses if already on SQLite or if a wallet.sqlite already exists. +bool DoConvertWalletToSQLite(int& nCopiedRet, std::string& strErrorRet) +{ + nCopiedRet = 0; + if (WalletSQLiteRuntimeActive()) + { + strErrorRet = "this wallet is already on the SQLite backend"; + return false; + } + string strDest = GetAppDir() + "/wallet.sqlite"; + if (FileExists(strDest.c_str())) + { + strErrorRet = strprintf("a SQLite wallet already exists at %s", strDest.c_str()); + return false; + } + if (!ExportActiveWalletToSQLite(strDest, nCopiedRet, strErrorRet)) + return false; + if (!WriteWalletBackendMarker("sqlite")) + { + strErrorRet = "the wallet was exported but the backend choice could not be saved"; + return false; + } + return true; +} + +int CmdWalletSQLiteExport(const std::string& strDest) +{ + AttachTerminal(); + + // Keep the command's own specific messages for the two conditions the + // shared helper folds into a generic error, so scripts and the tests that + // read this output see the same wording as before. + if (strDest.empty()) + { + fprintf(stderr, "Missing SQLite wallet export path.\n"); return 1; } - if (nCount != nCopied) + if (FileExists(strDest.c_str())) { - db.Close(); - RemoveSQLiteExportFiles(strDest); - fprintf(stderr, "SQLite wallet export count mismatch: copied %d, stored %d\n", - nCopied, nCount); + fprintf(stderr, "Refusing to overwrite existing SQLite wallet export: %s\n", + strDest.c_str()); + return 1; + } + + int nCopied = 0; + string strError; + if (!ExportActiveWalletToSQLite(strDest, nCopied, strError)) + { + fprintf(stderr, "Cannot export wallet to SQLite: %s\n", strError.c_str()); return 1; } diff --git a/src/walletcmd.h b/src/walletcmd.h index d993856..38cbfdb 100644 --- a/src/walletcmd.h +++ b/src/walletcmd.h @@ -75,6 +75,15 @@ int CmdWalletStorageCheck(); // not change which backend the runtime wallet opens. int CmdWalletSQLiteExport(const std::string& strDest); +// Export the currently-loaded wallet to a SQLite file (shared by the command +// above and the GUI wizard). Returns false with an error on any failure. +bool ExportActiveWalletToSQLite(const std::string& strDest, int& nCopiedRet, + std::string& strErrorRet); + +// Convert the running Berkeley DB wallet to SQLite and record the backend +// marker so the next start opens it. wallet.dat is left untouched as fallback. +bool DoConvertWalletToSQLite(int& nCopiedRet, std::string& strErrorRet); + // Diagnostic/migration staging: compare a SQLite wallet export with the current // wallet.dat raw records. Prints counts only, never record keys or values. int CmdWalletSQLiteVerify(const std::string& strPath); -- cgit v1.2.3 From 0cdcaee550093707b92827e7457ef33c1bf70fb5 Mon Sep 17 00:00:00 2001 From: bitflash Date: Wed, 19 Aug 2026 11:34:12 -0300 Subject: selftest: unwrap system() exit status on Linux (RunBitflashChild) The Linux branch of the child-process helper returned system()s raw wait status instead of the exit code, so an exit code of 2 read back as 512. Every assertion that checks a specific code (== 2), like the locked recovery-audit test, failed only on Linux while the node itself was correct. Windows was unaffected (_spawnv returns the code directly). Unwrap with WIFEXITED/WEXITSTATUS so both platforms return the same value. Test harness only; no change to node behavior. --- src/selftest.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) (limited to 'src/selftest.cpp') diff --git a/src/selftest.cpp b/src/selftest.cpp index a9c73fe..dc9936e 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -10,6 +10,9 @@ #include "bip32.h" #include "proxy.h" #include "selftest.h" +#ifndef _WIN32 +#include // WIFEXITED/WEXITSTATUS for the child-process helper +#endif #include "sockcount.h" #include "tor.h" #include "walletcmd.h" @@ -2110,7 +2113,13 @@ static int RunBitflashChild(const string& strExe, strCmd += " > " + QuoteCommandArg(*pOutputFile) + " 2>&1"; else strCmd += " > /dev/null 2>&1"; - return system(strCmd.c_str()); + // system() returns a wait status, not the exit code; unwrap it so callers + // see the same value the Windows _spawnv path returns (e.g. exit code 2 is + // 2, not 512). Without this every "== 2" assertion fails only on Linux. + int nSysRet = system(strCmd.c_str()); + if (nSysRet != -1 && WIFEXITED(nSysRet)) + return WEXITSTATUS(nSysRet); + return nSysRet; #endif } -- cgit v1.2.3 From 28a2656d22a193fcd92d225964da5a01e8183623 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Thu, 20 Aug 2026 15:37:44 -0300 Subject: net: add isolated testnet parameters --- README.md | 3 ++- src/Makefile | 1 + src/main.cpp | 16 +++++++++--- src/main_gui.cpp | 7 ++++-- src/makefile.mingw | 1 + src/net.cpp | 32 +++++++++++++++++++++-- src/net.h | 20 +++++++++------ src/readme.txt | 3 ++- src/selftest.cpp | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 9 files changed, 140 insertions(+), 17 deletions(-) (limited to 'src/selftest.cpp') diff --git a/README.md b/README.md index 94c727d..4843f5c 100644 --- a/README.md +++ b/README.md @@ -355,7 +355,8 @@ Other options worth knowing: ```bash -datadir=PATH # wallet and chain data elsewhere --port=N # P2P listen port, default 8433 +-testnet # isolated test network datadir, port 18433, and message magic +-port=N # P2P listen port, default 8433; testnet default 18433 -socks=HOST:PORT # SOCKS5 for outbound Nostr, .btf relay, and onion peer dials -tor[=HOST:PORT] # Tor mode; default local Tor SOCKS5 proxy is 127.0.0.1:9050 -managedtor[=PATH] # start Tor, create a hidden service, advertise its onion diff --git a/src/Makefile b/src/Makefile index b83ff58..ab0b307 100644 --- a/src/Makefile +++ b/src/Makefile @@ -193,6 +193,7 @@ tests: test_bip32 bitflash-node ./bitflash-node -selftest=wallet-crypto ./bitflash-node -selftest=wallet-encrypt ./bitflash-node -selftest=net-message + ./bitflash-node -selftest=network-params ./bitflash-node -selftest=consensus-limits ./bitflash-node -selftest=pool-stratum ./bitflash-node -selftest=parse-money diff --git a/src/main.cpp b/src/main.cpp index 5bd4028..6fd8c82 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2497,11 +2497,21 @@ string GetAppDir() { return "."; } - static bool fMkdirDone; - if (!fMkdirDone) + static string strCreatedBaseDir; + if (strCreatedBaseDir != strDir) { - fMkdirDone = true; _mkdir(strDir.c_str()); + strCreatedBaseDir = strDir; + } + if (IsTestNet()) + { + strDir += "/testnet"; + static string strCreatedTestnetDir; + if (strCreatedTestnetDir != strDir) + { + _mkdir(strDir.c_str()); + strCreatedTestnetDir = strDir; + } } return strDir; } diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 943799f..0a961bf 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -118,7 +118,7 @@ static void PrintUsage() printf(" wallet-crypto, wallet-encrypt, wallet-portability,\n"); printf(" net-message,\n"); printf(" consensus-limits, pool-stratum,\n"); - printf(" parse-money, socks5-proxy, or managed-tor\n"); + printf(" parse-money, network-params, socks5-proxy, or managed-tor\n"); printf("\n"); printf("Mining mode:\n"); printf(" /operator\n"); @@ -143,7 +143,8 @@ static void PrintUsage() printf(" /onionservice=HOST.onion:PORT (advertise this node's Tor hidden service)\n"); printf("\n"); printf("Network:\n"); - printf(" /port=N (P2P listen port, default 8433)\n"); + printf(" /testnet (isolated test network datadir, port, and magic)\n"); + printf(" /port=N (P2P listen port, default 8433; testnet 18433)\n"); printf(" /socks=HOST:PORT (SOCKS5 proxy for Nostr, .btf relay, and onion dials)\n"); printf(" /tor[=HOST:PORT] (Tor mode; default SOCKS5 proxy is 127.0.0.1:9050)\n"); printf(" /managedtor[=PATH] (start Tor, create a hidden service, advertise its onion)\n"); @@ -205,6 +206,8 @@ static void ParseStartupArguments(int argc, char* argv[]) if (arg(argc,argv,"/datadir") || arg(argc,argv,"-datadir")) strSetDataDir = argval2(argc, argv, "/datadir", "-datadir"); + SelectNetworkParams(arg(argc, argv, "/testnet") || arg(argc, argv, "-testnet")); + if (arg(argc,argv,"/debug") || arg(argc,argv,"-debug")) fDebug = true; diff --git a/src/makefile.mingw b/src/makefile.mingw index 55f186b..910e025 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -116,6 +116,7 @@ tests: test_bip32.exe bitflash.exe ./bitflash.exe -selftest=wallet-backend-default ./bitflash.exe -selftest=wallet-convert ./bitflash.exe -selftest=net-message + ./bitflash.exe -selftest=network-params ./bitflash.exe -selftest=consensus-limits ./bitflash.exe -selftest=pool-stratum ./bitflash.exe -selftest=socks5-proxy diff --git a/src/net.cpp b/src/net.cpp index 4603d2d..ecfd565 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -50,10 +50,14 @@ static int BtfPoll(BtfPollFd* pfd, unsigned int nfd, int nTimeoutMs) // // Global state variables // +static const char pchMainnetMessageStart[4] = { 0xbf, 0x20, 0x5c, 0xfd }; +static const char pchTestnetMessageStart[4] = { (char)0xce, (char)0xe2, (char)0xc0, (char)0xff }; +static bool fTestNet = false; +char pchMessageStart[4] = { 0xbf, 0x20, 0x5c, 0xfd }; bool fClient = false; uint64 nLocalServices = (fClient ? 0 : NODE_NETWORK); -CAddress addrLocalHost(0, DEFAULT_PORT, nLocalServices); -unsigned short nListenPort = DEFAULT_PORT; // local P2P port (tunable via /port) +CAddress addrLocalHost(0, GetDefaultPort(), nLocalServices); +unsigned short nListenPort = GetDefaultPort(); // local P2P port (tunable via /port) CNode nodeLocalHost(INVALID_SOCKET, CAddress("127.0.0.1", nLocalServices)); CNode* pnodeLocalHost = &nodeLocalHost; bool fShutdown = false; @@ -90,6 +94,29 @@ static int64 nBtfHandshakeSilent = 0; static string strBtfLastDialFail; static string strBtfLastHandshakeTimeout; +bool IsTestNet() +{ + return fTestNet; +} + +unsigned short GetDefaultPort() +{ + return htons(fTestNet ? TESTNET_PORT : MAINNET_PORT); +} + +void SelectNetworkParams(bool fTestNetIn) +{ + if (fTestNet == fTestNetIn) + return; + + fTestNet = fTestNetIn; + memcpy(pchMessageStart, + fTestNet ? pchTestnetMessageStart : pchMainnetMessageStart, + sizeof(pchMessageStart)); + nListenPort = GetDefaultPort(); + addrLocalHost.port = nListenPort; +} + void BtfChurnNoteResolveAttempt() { CRITICAL_BLOCK(cs_btfChurn) @@ -288,6 +315,7 @@ string GetDiagnosticsText() int nMedian = GetPeerMedianHeight(); str += "Bitflash node diagnostics\n"; + str += strprintf(" network %s\n", IsTestNet() ? "testnet" : "mainnet"); str += strprintf(" uptime %s\n", FormatAge(nNodeStartTime ? nNow - nNodeStartTime : -1).c_str()); if (nMedian < 0) diff --git a/src/net.h b/src/net.h index e3d4a93..56f1341 100644 --- a/src/net.h +++ b/src/net.h @@ -13,7 +13,8 @@ class CBlockIndex; -static const unsigned short DEFAULT_PORT = htons(8433); +static const unsigned short MAINNET_PORT = 8433; +static const unsigned short TESTNET_PORT = 18433; static const unsigned int PUBLISH_HOPS = 5; enum { @@ -26,6 +27,9 @@ enum bool GetMyExternalIP(unsigned int& ipRet); +bool IsTestNet(); +unsigned short GetDefaultPort(); +void SelectNetworkParams(bool fTestNetIn); CNode* ConnectNodeBtf(const string& strBtfAddr); CNode* ConnectNodeBtfResolved(const string& strBtfAddr, const string& strMeeting, const string& strOnion, const unsigned char enc_pub[32], @@ -219,15 +223,17 @@ void CheckForShutdown(int n); // The message start string is designed to be unlikely to occur in normal data. // The characters are rarely used upper ascii, not valid as UTF-8, and produce // a large 4-byte int at any alignment. -// Magic bytes unique to the Bitflash network (0xbf = "BF"). Second byte bumped -// to 0x20 for the 2026 stable relaunch so the old test chain can't interfere. -static const char pchMessageStart[4] = { 0xbf, 0x20, 0x5c, 0xfd }; +// Magic bytes unique to the selected Bitflash network. Mainnet uses 0xbf = +// "BF"; testnet swaps the whole sequence so a test node cannot handshake with +// mainnet before consensus-level testnet activation exists. +extern char pchMessageStart[4]; class CMessageHeader { public: enum { COMMAND_SIZE=12 }; - char pchMessageStart[sizeof(::pchMessageStart)]; + enum { MESSAGE_START_SIZE=4 }; + char pchMessageStart[MESSAGE_START_SIZE]; char pchCommand[COMMAND_SIZE]; unsigned int nMessageSize; @@ -318,7 +324,7 @@ public: nServices = 0; memcpy(pchReserved, pchIPv4, sizeof(pchReserved)); ip = 0; - port = DEFAULT_PORT; + port = GetDefaultPort(); nTime = GetAdjustedTime(); nLastFailed = 0; } @@ -348,7 +354,7 @@ public: nServices = nServicesIn; memcpy(pchReserved, pchIPv4, sizeof(pchReserved)); ip = 0; - port = DEFAULT_PORT; + port = GetDefaultPort(); nTime = GetAdjustedTime(); nLastFailed = 0; diff --git a/src/readme.txt b/src/readme.txt index 752be66..5d62791 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -41,7 +41,8 @@ The resulting executable is src/bitflash.exe. Command-line options (in addition to the original ones) ------------------------------------------------------- - /port=N listen on P2P port N (default 8433) + /testnet use isolated test network datadir, port 18433, and message magic + /port=N listen on P2P port N (default 8433; testnet default 18433) /socks=HOST:PORT route outbound Nostr, .btf rendezvous, and direct onion peer dials through a SOCKS5 proxy, e.g. /socks=127.0.0.1:9050 for local Tor diff --git a/src/selftest.cpp b/src/selftest.cpp index dc9936e..30bd28e 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -3076,6 +3076,76 @@ static int RunNetMessageSelfTest() return nFail == 0 ? 0 : 1; } +static int RunNetworkParamsSelfTest() +{ + fflush(stdout); + printf("network-params self-test\n"); + + int nFail = 0; + std::string strOldDataDir = strSetDataDir; + try + { + SelectNetworkParams(false); + nFail += Check(!IsTestNet(), "starts on mainnet after explicit selection") ? 0 : 1; + nFail += Check(ntohs(GetDefaultPort()) == MAINNET_PORT, + "mainnet default P2P port is 8433") ? 0 : 1; + CAddress mainAddr("127.0.0.1"); + nFail += Check(mainAddr.port == htons(MAINNET_PORT), + "mainnet address parsing uses the mainnet default port") ? 0 : 1; + CMessageHeader mainHeader("ping", 0); + char mainMagic[4]; + memcpy(mainMagic, mainHeader.pchMessageStart, sizeof(mainMagic)); + + SelectNetworkParams(true); + nFail += Check(IsTestNet(), "testnet selection is sticky") ? 0 : 1; + nFail += Check(ntohs(GetDefaultPort()) == TESTNET_PORT, + "testnet default P2P port is 18433") ? 0 : 1; + CAddress testAddr("127.0.0.1"); + nFail += Check(testAddr.port == htons(TESTNET_PORT), + "testnet address parsing uses the testnet default port") ? 0 : 1; + CMessageHeader testHeader("ping", 0); + nFail += Check(memcmp(mainMagic, testHeader.pchMessageStart, sizeof(mainMagic)) != 0, + "testnet message magic differs from mainnet") ? 0 : 1; + + std::string tmp; + if (!MakeTempDir(tmp)) + { + nFail += Check(false, "creates a temporary data directory") ? 0 : 1; + } + else + { + strSetDataDir = tmp; + SelectNetworkParams(false); + std::string mainDir = GetAppDir(); + SelectNetworkParams(true); + std::string testDir = GetAppDir(); + nFail += Check(mainDir == tmp, + "mainnet uses the selected data directory") ? 0 : 1; + nFail += Check(testDir == tmp + "/testnet", + "testnet stores data below a testnet subdirectory") ? 0 : 1; + RemoveTree(tmp); + } + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + strSetDataDir = strOldDataDir; + SelectNetworkParams(false); + + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + static CBlock MakeSizedConsensusBlock(size_t nScriptBytes) { CTransaction tx; @@ -3517,6 +3587,8 @@ int RunSelfTest(const std::string& name) return RunWalletPortabilitySelfTest(); if (name == "net-message") return RunNetMessageSelfTest(); + if (name == "network-params") + return RunNetworkParamsSelfTest(); if (name == "consensus-limits") return RunConsensusLimitsSelfTest(); if (name == "pool-stratum") @@ -3529,6 +3601,6 @@ int RunSelfTest(const std::string& name) return RunManagedTorSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, db-env-reopen, wallet-sqlite, wallet-sqlite-migration, wallet-crypto, wallet-encrypt, wallet-sqlite-encrypt, wallet-backend-default, wallet-convert, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, db-env-reopen, wallet-sqlite, wallet-sqlite-migration, wallet-crypto, wallet-encrypt, wallet-sqlite-encrypt, wallet-backend-default, wallet-convert, wallet-portability, net-message, network-params, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); return 1; } -- cgit v1.2.3 From 56be66dd66e64e6d21eadd80a808b4627921059f Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Thu, 20 Aug 2026 17:06:08 -0300 Subject: chain: add isolated testnet genesis --- README.md | 2 +- src/main.cpp | 103 +++++++++++++++++++++++++++++++++++++++++++------------ src/main.h | 2 ++ src/main_gui.cpp | 5 +-- src/readme.txt | 2 +- src/selftest.cpp | 21 +++++++++--- 6 files changed, 105 insertions(+), 30 deletions(-) (limited to 'src/selftest.cpp') diff --git a/README.md b/README.md index 4843f5c..77227fa 100644 --- a/README.md +++ b/README.md @@ -355,7 +355,7 @@ Other options worth knowing: ```bash -datadir=PATH # wallet and chain data elsewhere --testnet # isolated test network datadir, port 18433, and message magic +-testnet # isolated test network genesis, datadir, port 18433, and magic -port=N # P2P listen port, default 8433; testnet default 18433 -socks=HOST:PORT # SOCKS5 for outbound Nostr, .btf relay, and onion peer dials -tor[=HOST:PORT] # Tor mode; default local Tor SOCKS5 proxy is 127.0.0.1:9050 diff --git a/src/main.cpp b/src/main.cpp index 6fd8c82..5d266ff 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -41,12 +41,30 @@ unsigned int nTransactionsUpdated = 0; map mapNextTx; map mapBlockIndex; -// Bitflash genesis -- mined under RandomX PoW on 2026-07-22 -// (nTime 1753142400, nBits 0x1f0fffff, RandomX key "Bitflash/RandomX/v1/...") -// Bitflash 2026 stable relaunch genesis (2-min blocks, RandomX). Mined values. -unsigned int GENESIS_NONCE = 3141; -uint256 hashGenesisBlock("0x5bd7cb255d814e48cebcdfb72da4dc87b34bd774227f8ceb546c5640f4bdc169"); -uint256 hashGenesisMerkleRoot("0x1a45b4482532abb29b10e234d3f13132230525a339ecea91658ffa675a5b1325"); +// Bitflash mainnet genesis -- mined under RandomX PoW for the 2026 stable +// relaunch. Testnet gets its own resettable genesis below so block indexes, +// headers, and wallet scans cannot be accidentally mixed across networks. +static const unsigned int MAINNET_GENESIS_TIME = 1753315200; // 2026-07-24 00:00:00 UTC +static const unsigned int MAINNET_GENESIS_NONCE = 3141; +static const char* MAINNET_GENESIS_TIMESTAMP = + "Bitflash 24/Jul/2026 Fair launch: one CPU one vote, no premine"; +static const char* MAINNET_GENESIS_HASH = + "0x5bd7cb255d814e48cebcdfb72da4dc87b34bd774227f8ceb546c5640f4bdc169"; +static const char* MAINNET_GENESIS_MERKLE = + "0x1a45b4482532abb29b10e234d3f13132230525a339ecea91658ffa675a5b1325"; + +static const unsigned int TESTNET_GENESIS_TIME = 1787184000; // 2026-08-20 00:00:00 UTC +static const unsigned int TESTNET_GENESIS_NONCE = 3199; +static const char* TESTNET_GENESIS_TIMESTAMP = + "Bitflash testnet 20/Aug/2026: resettable public test chain"; +static const char* TESTNET_GENESIS_HASH = + "0xa9d11c6d697bcb7aea0653bc088b12dcf9f44e732df205162cf085fd9d9963eb"; +static const char* TESTNET_GENESIS_MERKLE = + "0x510b293105e3ff85b8d4c3920e357f85d1a86bf199eb4e7d11f04f380030ec5c"; + +unsigned int GENESIS_NONCE = MAINNET_GENESIS_NONCE; +uint256 hashGenesisBlock(MAINNET_GENESIS_HASH); +uint256 hashGenesisMerkleRoot(MAINNET_GENESIS_MERKLE); CBlockIndex* pindexGenesisBlock = NULL; int nBestHeight = -1; uint256 hashBestChain = 0; @@ -117,6 +135,63 @@ static std::atomic gParticipantHashRateX1000{0}; static std::mutex gParticipantStatusMutex; static std::string gParticipantStatus; +void SelectGenesisParams(bool fTestNetIn) +{ + static bool fGenesisParamsSelected = false; + static bool fGenesisParamsTestNet = false; + if (fGenesisParamsSelected && fGenesisParamsTestNet == fTestNetIn) + return; + + fGenesisParamsSelected = true; + fGenesisParamsTestNet = fTestNetIn; + + if (fTestNetIn) + { + GENESIS_NONCE = TESTNET_GENESIS_NONCE; + hashGenesisBlock = uint256(TESTNET_GENESIS_HASH); + hashGenesisMerkleRoot = uint256(TESTNET_GENESIS_MERKLE); + } + else + { + GENESIS_NONCE = MAINNET_GENESIS_NONCE; + hashGenesisBlock = uint256(MAINNET_GENESIS_HASH); + hashGenesisMerkleRoot = uint256(MAINNET_GENESIS_MERKLE); + } +} + +void SelectChainParams(bool fTestNetIn) +{ + SelectNetworkParams(fTestNetIn); + SelectGenesisParams(fTestNetIn); +} + +static CBlock BuildGenesisBlock() +{ + const char* pszTimestamp = IsTestNet() ? + TESTNET_GENESIS_TIMESTAMP : MAINNET_GENESIS_TIMESTAMP; + + CTransaction txNew; + txNew.vin.resize(1); + txNew.vout.resize(1); + txNew.vin[0].scriptSig = CScript() << 486604799 << CBigNum(4) << + vector((unsigned char*)pszTimestamp, + (unsigned char*)pszTimestamp + strlen(pszTimestamp)); + txNew.vout[0].nValue = 50 * COIN; + txNew.vout[0].scriptPubKey = CScript() << + CBigNum("0x5F1DF16B2B704C8A578D0BBAF74D385CDE12C11EE50455F3C438EF4C3FBCF649B6DE611FEAE06279A60939E028A8D65C10B73071A6F16719274855FEB0FD8A6704") << + OP_CHECKSIG; + + CBlock block; + block.vtx.push_back(txNew); + block.hashPrevBlock = 0; + block.hashMerkleRoot = block.BuildMerkleTree(); + block.nVersion = 1; + block.nTime = IsTestNet() ? TESTNET_GENESIS_TIME : MAINNET_GENESIS_TIME; + block.nBits = 0x1f0fffff; // = bnProofOfWorkLimit (Bitflash RandomX floor) + block.nNonce = GENESIS_NONCE; + return block; +} + void SetParticipantMiningStatus(const std::string& status) { // Updates the GUI status string only. Logging is done at each call site @@ -2664,21 +2739,7 @@ bool LoadBlockIndex(bool fAllowNew) // vMerkleTree: 4a5e1e // Bitflash genesis block -- own header (fair launch, 2026 stable relaunch) - char* pszTimestamp = "Bitflash 24/Jul/2026 Fair launch: one CPU one vote, no premine"; - CTransaction txNew; - txNew.vin.resize(1); - txNew.vout.resize(1); - txNew.vin[0].scriptSig = CScript() << 486604799 << CBigNum(4) << vector((unsigned char*)pszTimestamp, (unsigned char*)pszTimestamp + strlen(pszTimestamp)); - txNew.vout[0].nValue = 50 * COIN; - txNew.vout[0].scriptPubKey = CScript() << CBigNum("0x5F1DF16B2B704C8A578D0BBAF74D385CDE12C11EE50455F3C438EF4C3FBCF649B6DE611FEAE06279A60939E028A8D65C10B73071A6F16719274855FEB0FD8A6704") << OP_CHECKSIG; - CBlock block; - block.vtx.push_back(txNew); - block.hashPrevBlock = 0; - block.hashMerkleRoot = block.BuildMerkleTree(); - block.nVersion = 1; - block.nTime = 1753315200; // 2026-07-24 00:00:00 UTC (stable relaunch) - block.nBits = 0x1f0fffff; // = bnProofOfWorkLimit (Bitflash RandomX floor) - block.nNonce = GENESIS_NONCE; + CBlock block = BuildGenesisBlock(); // If the nonce has not been found yet, mine the genesis once (RandomX) // and print the values to be hardcoded in the constants above. diff --git a/src/main.h b/src/main.h index dc5527e..793c4e8 100644 --- a/src/main.h +++ b/src/main.h @@ -59,6 +59,8 @@ extern map mapBlockIndex; extern uint256 hashGenesisBlock; extern uint256 hashGenesisMerkleRoot; extern unsigned int GENESIS_NONCE; +void SelectGenesisParams(bool fTestNetIn); +void SelectChainParams(bool fTestNetIn); extern CBlockIndex* pindexGenesisBlock; extern int nBestHeight; extern uint256 hashBestChain; diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 0a961bf..9f0fec0 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -143,7 +143,7 @@ static void PrintUsage() printf(" /onionservice=HOST.onion:PORT (advertise this node's Tor hidden service)\n"); printf("\n"); printf("Network:\n"); - printf(" /testnet (isolated test network datadir, port, and magic)\n"); + printf(" /testnet (isolated test network genesis, datadir, port, and magic)\n"); printf(" /port=N (P2P listen port, default 8433; testnet 18433)\n"); printf(" /socks=HOST:PORT (SOCKS5 proxy for Nostr, .btf relay, and onion dials)\n"); printf(" /tor[=HOST:PORT] (Tor mode; default SOCKS5 proxy is 127.0.0.1:9050)\n"); @@ -206,7 +206,8 @@ static void ParseStartupArguments(int argc, char* argv[]) if (arg(argc,argv,"/datadir") || arg(argc,argv,"-datadir")) strSetDataDir = argval2(argc, argv, "/datadir", "-datadir"); - SelectNetworkParams(arg(argc, argv, "/testnet") || arg(argc, argv, "-testnet")); + bool fUseTestNet = arg(argc, argv, "/testnet") || arg(argc, argv, "-testnet"); + SelectChainParams(fUseTestNet); if (arg(argc,argv,"/debug") || arg(argc,argv,"-debug")) fDebug = true; diff --git a/src/readme.txt b/src/readme.txt index 5d62791..810e8b7 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -41,7 +41,7 @@ The resulting executable is src/bitflash.exe. Command-line options (in addition to the original ones) ------------------------------------------------------- - /testnet use isolated test network datadir, port 18433, and message magic + /testnet use isolated test network genesis, datadir, port 18433, and message magic /port=N listen on P2P port N (default 8433; testnet default 18433) /socks=HOST:PORT route outbound Nostr, .btf rendezvous, and direct onion peer diff --git a/src/selftest.cpp b/src/selftest.cpp index 30bd28e..1a72e3a 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -3085,7 +3085,7 @@ static int RunNetworkParamsSelfTest() std::string strOldDataDir = strSetDataDir; try { - SelectNetworkParams(false); + SelectChainParams(false); nFail += Check(!IsTestNet(), "starts on mainnet after explicit selection") ? 0 : 1; nFail += Check(ntohs(GetDefaultPort()) == MAINNET_PORT, "mainnet default P2P port is 8433") ? 0 : 1; @@ -3095,8 +3095,11 @@ static int RunNetworkParamsSelfTest() CMessageHeader mainHeader("ping", 0); char mainMagic[4]; memcpy(mainMagic, mainHeader.pchMessageStart, sizeof(mainMagic)); + uint256 mainGenesis = hashGenesisBlock; + uint256 mainMerkle = hashGenesisMerkleRoot; + unsigned int mainNonce = GENESIS_NONCE; - SelectNetworkParams(true); + SelectChainParams(true); nFail += Check(IsTestNet(), "testnet selection is sticky") ? 0 : 1; nFail += Check(ntohs(GetDefaultPort()) == TESTNET_PORT, "testnet default P2P port is 18433") ? 0 : 1; @@ -3106,6 +3109,14 @@ static int RunNetworkParamsSelfTest() CMessageHeader testHeader("ping", 0); nFail += Check(memcmp(mainMagic, testHeader.pchMessageStart, sizeof(mainMagic)) != 0, "testnet message magic differs from mainnet") ? 0 : 1; + nFail += Check(hashGenesisBlock != mainGenesis && + hashGenesisMerkleRoot != mainMerkle && + GENESIS_NONCE != mainNonce, + "testnet genesis constants differ from mainnet") ? 0 : 1; + nFail += Check(hashGenesisBlock == uint256("0xa9d11c6d697bcb7aea0653bc088b12dcf9f44e732df205162cf085fd9d9963eb") && + hashGenesisMerkleRoot == uint256("0x510b293105e3ff85b8d4c3920e357f85d1a86bf199eb4e7d11f04f380030ec5c") && + GENESIS_NONCE == 3199, + "testnet genesis constants are pinned") ? 0 : 1; std::string tmp; if (!MakeTempDir(tmp)) @@ -3115,9 +3126,9 @@ static int RunNetworkParamsSelfTest() else { strSetDataDir = tmp; - SelectNetworkParams(false); + SelectChainParams(false); std::string mainDir = GetAppDir(); - SelectNetworkParams(true); + SelectChainParams(true); std::string testDir = GetAppDir(); nFail += Check(mainDir == tmp, "mainnet uses the selected data directory") ? 0 : 1; @@ -3138,7 +3149,7 @@ static int RunNetworkParamsSelfTest() } strSetDataDir = strOldDataDir; - SelectNetworkParams(false); + SelectChainParams(false); printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", nFail, nFail == 1 ? "" : "s"); -- cgit v1.2.3 From 6d4c3f153cf44cefead39b21191dfb33491bfa5a Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 23 Aug 2026 04:31:18 +0000 Subject: net: start every peer's stall clock stopped, not at whatever was in memory CNode's constructor never wrote nMsgWaitSince or nMsgWaitSize, so every peer began life with indeterminate values in the pair ProcessMessages uses to decide whether that peer has stopped sending, and the first partial message it read was measured against them. The guard restarts the clock only when nMsgWaitSince is 0 or the buffer has grown since the last look. Garbage in nMsgWaitSince is neither, so the very first look fell through to the timeout branch. A peer that merely split a message across two TCP segments -- header in one, body in the next, which is ordinary on any real link -- could be dropped as unreadable on first contact, before it had sent anything wrong and often before its version message. It was never reproducible either: what happened depended on what had last used that heap. The log reported the stall as roughly the age of the epoch: peer 127.0.0.1:8433 announced a 100 byte 'block' and has sent nothing more for 1786593923 s (0 of 100 payload bytes) -- dropping, this peer is unreadable Every node is running this. Introduced in 7f6e5ad, which added the pair to the class and to ProcessMessages but not to the constructor; the constructor went on initialising nIncompleteMessageStart, nIncompleteMessageSize and strIncompleteMessageCommand, the upstream members our implementation supersedes. 5490d66 removed their last reader from ProcessMessages, and nothing has read or written them since, so they go, with BTF_INCOMPLETE_MESSAGE_TIMEOUT_SECS -- decoy state in a networking struct is how this happened, and the merge ahead of us would carry it further. The initialiser list now follows declaration order, so a member missing from it is visible. The net-message selftest was asserting on those dead members too, which is why it could not have caught this: it read state no code path writes, and its stale-message case passed only when the garbage in nMsgWaitSince happened to look old. Rewired onto nMsgWaitSince / nMsgWaitSize / BTF_MSG_WAIT_MAX_SECS, and given the two cases it never covered -- a new peer's clock must start stopped, and a peer that is still delivering payload must not be dropped however old its clock is. Measured on the build host, 20 runs each: before, net-message failed 20/20 ("a fresh incomplete message does not disconnect immediately" failed 17/20 by itself). After, 20/20 pass. Rewired selftest against a reverted constructor fix: fails 20/20, deterministically, where the old suite caught it by luck or not at all. --- src/net.h | 16 +++++++--------- src/selftest.cpp | 37 +++++++++++++++++++++++++++++++------ 2 files changed, 38 insertions(+), 15 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/net.h b/src/net.h index 8b0245a..3c8c409 100644 --- a/src/net.h +++ b/src/net.h @@ -89,10 +89,9 @@ static const int BTF_HANDSHAKE_GRACE_SECS = 60; // A serialized object cannot be larger than MAX_SIZE, so no valid network // message needs the old 256 MB header allowance. Keep the wire cap aligned with -// what the deserializer can accept and bound how long one peer may sit on a -// partial payload after a complete header has arrived. +// what the deserializer can accept. How long a peer may sit on a partial +// payload after a complete header has arrived is BTF_MSG_WAIT_MAX_SECS, below. static const unsigned int MAX_PROTOCOL_MESSAGE_SIZE = MAX_SIZE; -static const int BTF_INCOMPLETE_MESSAGE_TIMEOUT_SECS = 2 * 60; // Ceiling on simultaneous connections. // @@ -656,9 +655,6 @@ public: int64 nLastSend; int64 nLastRecv; int64 nLastSendEmpty; - int64 nIncompleteMessageStart; - unsigned int nIncompleteMessageSize; - string strIncompleteMessageCommand; string strBtfAddr; string strBtfMeeting; @@ -699,11 +695,13 @@ public: nLastSend = 0; nLastRecv = 0; nLastSendEmpty = GetTime(); - nIncompleteMessageStart = 0; - nIncompleteMessageSize = 0; - strIncompleteMessageCommand.clear(); strBtfAddr.clear(); strBtfMeeting.clear(); + // Not waiting on anything yet. Left indeterminate, the first partial + // message this peer sends is measured against whatever was in this + // memory, and ProcessMessages drops it as unreadable on first contact. + nMsgWaitSince = 0; + nMsgWaitSize = 0; nStartingHeight = -1; // Push a version message diff --git a/src/selftest.cpp b/src/selftest.cpp index ff0289b..d12c96b 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1105,6 +1105,13 @@ static int RunNetMessageSelfTest() int nFail = 0; try { + // A peer that has not yet been kept waiting must not look like one that + // has been waiting since the epoch. This is the whole bug: the stall + // clock lives in CNode, so only the constructor can start it at zero. + CNode opened(INVALID_SOCKET, CAddress("127.0.0.1")); + nFail += Check(opened.nMsgWaitSince == 0 && opened.nMsgWaitSize == 0, + "a new peer starts with its stall clock stopped") ? 0 : 1; + CNode complete(INVALID_SOCKET, CAddress("127.0.0.1")); complete.nVersion = VERSION; complete.vRecv << CMessageHeader("ping", 0); @@ -1119,20 +1126,38 @@ static int RunNetMessageSelfTest() nFail += Check(ProcessMessages(&partial), "an incomplete message returns cleanly") ? 0 : 1; nFail += Check(partial.vRecv.size() == nPartialBefore, "an incomplete message keeps one header in the buffer") ? 0 : 1; - nFail += Check(partial.nIncompleteMessageStart != 0, - "an incomplete message starts a timeout clock") ? 0 : 1; + nFail += Check(partial.nMsgWaitSince != 0, + "an incomplete message starts the stall clock") ? 0 : 1; + nFail += Check(partial.nMsgWaitSize == 0, + "the stall clock records how much payload had arrived") ? 0 : 1; nFail += Check(!partial.fDisconnect, "a fresh incomplete message does not disconnect immediately") ? 0 : 1; + // Still arriving, just slowly: more payload is here than when the clock + // was started, so the clock restarts however old it was. Timing the + // wait instead of the stall would drop this peer. + CNode slow(INVALID_SOCKET, CAddress("127.0.0.1")); + slow.nVersion = VERSION; + slow.vRecv << CMessageHeader("block", 100); + slow.vRecv.insert(slow.vRecv.end(), 50, '\0'); + slow.nMsgWaitSince = GetTime() - BTF_MSG_WAIT_MAX_SECS - 1; + slow.nMsgWaitSize = 10; + ProcessMessages(&slow); + nFail += Check(!slow.fDisconnect, + "a peer still sending payload is not dropped") ? 0 : 1; + nFail += Check(slow.nMsgWaitSize == 50, + "progress restarts the stall clock") ? 0 : 1; + + // Nothing more has arrived since the clock started, and the clock is + // older than the limit. CNode stale(INVALID_SOCKET, CAddress("127.0.0.1")); stale.nVersion = VERSION; stale.vRecv << CMessageHeader("block", 100); - stale.nIncompleteMessageStart = GetTime() - BTF_INCOMPLETE_MESSAGE_TIMEOUT_SECS - 1; - stale.nIncompleteMessageSize = 100; - stale.strIncompleteMessageCommand = "block"; + stale.nMsgWaitSince = GetTime() - BTF_MSG_WAIT_MAX_SECS - 1; + stale.nMsgWaitSize = 0; ProcessMessages(&stale); nFail += Check(stale.fDisconnect, - "a stale incomplete message disconnects the peer") ? 0 : 1; + "a stalled incomplete message disconnects the peer") ? 0 : 1; CNode oversized(INVALID_SOCKET, CAddress("127.0.0.1")); oversized.nVersion = VERSION; -- cgit v1.2.3 From cd4e86d431c3f5848183ca362175d1cb3edfa107 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 23 Aug 2026 07:52:52 +0000 Subject: selftest: cover the payout ledger properties this series fixes A new `payout-ledger` self-test, 40 assertions, wired into RunSelfTest and into `make -C src tests`. It runs against a scratch data directory and an in-memory wallet map: it never opens wallet.dat, never touches a real datadir, and never sends anything. What it asserts, defect by defect: - a restart does not duplicate an in-flight record. A matured round is exploded, the process state is thrown away and reloaded from the files twice, and the in-flight list must still hold exactly one record per recipient. Both guards are checked separately -- once with the round-level set restored, and once with it deliberately emptied so only the per-recipient check against gPaying can save it. A paying file already containing the duplicate the old build wrote must collapse on load, with the attempt counts merged upwards rather than reset. - state updates address a slot, not a value. Two records equal in every field -- exactly what the old code produced -- and rescheduling or forgetting one must land on the one it was given. - the ledger file cannot be published from a stale snapshot. An older snapshot published after a newer one must not appear on disk, and four threads publishing 64 large images of the same file in different orders must leave it parseable and equal to the highest version, whatever order they finish in. - a payment with only a legacy comment is recognised as already paid, and the fallback's limits hold: it requires the right recipient, the exact amount and the right height; one legacy transaction cannot settle a second debt; a transaction carrying another round's structured metadata is never accepted as legacy evidence; and the structured match still wins where it applies. - grandfathered zero-hash rounds do not answer for each other, both as a direct test of the round comparison and end to end -- recording a txid against one zero-hash round must leave the other still owed. - the retry clock that is actually used survives a restart. - a forfeit is published rather than silently dropped: the ledger loses that recipient and only that recipient, and the round proof records it with a reason instead of going on advertising the address as owed. - on-disk compatibility, checked against the real shapes: the live production pending_payouts.json (amounts / blockHash / blockHeight / matureAtHeight and nothing else), the merged build's shape including the retryAfter this build no longer writes, and a round trip through this build. Each assertion was verified to fail when its defect is put back: seven mutations were applied one at a time to a scratch copy and every one was caught. Notably, re-introducing either half of the duplicate-record fix alone is caught by its own assertion while the other half still prevents the duplicate -- which is the defence in depth this series was asked for, checked rather than asserted. --- src/Makefile | 1 + src/rpc.cpp | 671 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/selftest.cpp | 5 +- 3 files changed, 676 insertions(+), 1 deletion(-) (limited to 'src/selftest.cpp') diff --git a/src/Makefile b/src/Makefile index 1166daf..89ba559 100644 --- a/src/Makefile +++ b/src/Makefile @@ -192,6 +192,7 @@ tests: test_bip32 bitflash-node ./bitflash-node -selftest=net-message ./bitflash-node -selftest=consensus-limits ./bitflash-node -selftest=pool-stratum + ./bitflash-node -selftest=payout-ledger ./bitflash-node -selftest=parse-money ./bitflash-node -selftest=socks5-proxy diff --git a/src/rpc.cpp b/src/rpc.cpp index 45130ff..cebc349 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -38,6 +38,7 @@ #include #include +#include #include #include #include @@ -2749,6 +2750,676 @@ static bool HandleLine(Miner* m, const std::string& rawLine, return true; } +// --------------------------------------------------------------------------- +// payout-ledger self-test +// +// Regression cover for the payout defects fixed in this series. Everything +// here runs against a scratch data directory and an in-memory wallet map; it +// never opens wallet.dat, never touches a real datadir, and never sends +// anything. +// --------------------------------------------------------------------------- + +static bool PayoutTestReadFile(const std::string& path, std::string& out) +{ + FILE* f = fopen(path.c_str(), "r"); + if (!f) return false; + fseek(f, 0, SEEK_END); + long sz = ftell(f); + fseek(f, 0, SEEK_SET); + out.assign((size_t)(sz > 0 ? sz : 0), '\0'); + if (sz > 0 && fread(&out[0], 1, (size_t)sz, f) != (size_t)sz) { + fclose(f); + return false; + } + fclose(f); + return true; +} + +static bool PayoutTestWriteFile(const std::string& path, const std::string& data) +{ + FILE* f = fopen(path.c_str(), "w"); + if (!f) return false; + bool ok = data.empty() || fwrite(data.c_str(), 1, data.size(), f) == data.size(); + return fclose(f) == 0 && ok; +} + +// Wipe every piece of payout state this test touches, so each section starts +// from the same place and a "restart" can be simulated by reloading from disk. +static void PayoutTestResetState() +{ + { + std::lock_guard lk(gPayoutLedgerMutex); + gPendingPayouts.clear(); + } + { + std::lock_guard lk(gPayoutExecMutex); + gPaying.clear(); + gQueuedPayouts.clear(); + } + { + std::lock_guard lk(gPoolRoundsMutex); + gPoolRounds.clear(); + } + { + std::lock_guard lk(gClaimedTxidMutex); + gLegacyClaimedTxids.clear(); + } +} + +// A BTF address whose hash160 is entirely under the test's control, so the +// output script the probe compares against can be built without a wallet. +static std::string PayoutTestAddress(unsigned char seed) +{ + uint160 h = 0; + for (int i = 0; i < 20; i++) + ((unsigned char*)&h)[i] = (unsigned char)(seed + i); + return Hash160ToAddress(h); +} + +static CScript PayoutTestScriptFor(const std::string& address) +{ + uint160 h160; + CScript sc; + if (AddressToHash160(address, h160)) + sc << OP_DUP << OP_HASH160 << h160 << OP_EQUALVERIFY << OP_CHECKSIG; + return sc; +} + +// Put a transaction in the wallet map that looks exactly like one this pool +// sent. `nonce` only exists to keep the hashes distinct. +static std::string PayoutTestAddWalletTx(const std::string& comment, + const std::string& address, + int64 amount, + int nonce, + const std::string& structuredId) +{ + CWalletTx wtx; + wtx.vin.resize(1); + wtx.vin[0].prevout.SetNull(); + wtx.vin[0].scriptSig << nonce; + wtx.vout.resize(1); + wtx.vout[0].nValue = amount; + wtx.vout[0].scriptPubKey = PayoutTestScriptFor(address); + if (!comment.empty()) + wtx.mapValue["comment"] = comment; + if (!structuredId.empty()) { + wtx.mapValue["pool_payout_id"] = structuredId; + wtx.mapValue["pool_payout_address"] = address; + wtx.mapValue["pool_payout_amount"] = strprintf("%lld", amount); + } + uint256 hash = wtx.GetHash(); + CRITICAL_BLOCK(cs_mapWallet) + mapWallet[hash] = wtx; + return hash.GetHex(); +} + +int RunPayoutLedgerSelfTest() +{ + int nFail = 0; + auto check = [&](bool ok, const char* msg) { + fprintf(stdout, " %s %s\n", ok ? "ok" : "FAIL", msg); + if (!ok) nFail++; + }; + + fprintf(stdout, "payout-ledger self-test\n"); + + const std::string savedDataDir = strSetDataDir; + const int savedBestHeight = nBestHeight; + + // Scratch data directory in the current working directory. GetAppDir() + // follows strSetDataDir, so this is what PayoutFilePath() and + // PayingFilePath() resolve against for the rest of the test. + std::string dir = strprintf("payout_selftest_%d", (int)GetTime()); +#ifdef _WIN32 + _mkdir(dir.c_str()); +#else + mkdir(dir.c_str(), 0700); +#endif + strSetDataDir = dir; + fprintf(stdout, " scratch datadir: %s\n", dir.c_str()); + + const std::string ledgerPath = PayoutFilePath(); + const std::string payingPath = PayingFilePath(); + const std::string addrA = PayoutTestAddress(0x11); + const std::string addrB = PayoutTestAddress(0x55); + + // -- 1. A restart must not duplicate an in-flight record ----------------- + // + // The ledger entry is grandfathered (zero hash) on purpose: it keeps + // PayoutBlockStillOnChain out of the picture, which needs a block index, + // and it exercises the height fallback at the same time. + { + PayoutTestResetState(); + remove(ledgerPath.c_str()); + remove(payingPath.c_str()); + + PendingPayout pp; + pp.blockHeight = 4242; + pp.blockHash = 0; + pp.matureAtHeight = 4242; + pp.amounts[addrA] = 10 * COIN; + pp.amounts[addrB] = 20 * COIN; + VersionedBlob snap; + { + std::lock_guard lk(gPayoutLedgerMutex); + gPendingPayouts.push_back(pp); + snap = SerializePendingPayoutsLocked(); + } + PublishPendingPayouts(snap); + + nBestHeight = 5000; + FlushMaturePayouts(); + size_t afterFirstFlush = 0; + { + std::lock_guard lk(gPayoutExecMutex); + afterFirstFlush = gPaying.size(); + } + check(afterFirstFlush == 2, + "a matured round explodes into one record per unpaid recipient"); + + // Restart: drop everything in memory and come back from the files. + for (int restart = 1; restart <= 2; restart++) { + PayoutTestResetState(); + LoadPendingPayouts(); + LoadPaying(); + ReconcilePayouts(); + + size_t queued = 0; + { + std::lock_guard lk(gPayoutExecMutex); + queued = gQueuedPayouts.size(); + } + if (restart == 1) + check(queued == 1, + "startup rebuilds the exploded-round set from the restored" + " in-flight list"); + + FlushMaturePayouts(); + size_t n = 0; + { + std::lock_guard lk(gPayoutExecMutex); + n = gPaying.size(); + } + check(n == 2, restart == 1 + ? "a restart does not duplicate an in-flight record" + : "a second restart does not duplicate one either"); + } + + // The other half of the guard: even with the round-level dedupe set + // deliberately emptied, the per-recipient check against gPaying must + // stop a second standing order for the same money. + { + std::lock_guard lk(gPayoutExecMutex); + gQueuedPayouts.clear(); + } + FlushMaturePayouts(); + size_t n = 0; + { + std::lock_guard lk(gPayoutExecMutex); + n = gPaying.size(); + } + check(n == 2, + "the flush refuses a second record for a (round, address) already" + " in flight even with the round set lost"); + } + + // -- 2. Duplicates already on disk are collapsed as they are read -------- + { + PayoutTestResetState(); + nlohmann::json arr = nlohmann::json::array(); + for (int i = 0; i < 2; i++) { + nlohmann::json o; + o["blockHeight"] = 4242; + o["blockHash"] = uint256(0).ToString(); + o["matureAtHeight"] = 4242; + o["address"] = addrA; + o["amount"] = (int64)(10 * COIN); + o["retries"] = i == 0 ? 7 : 0; + o["deferrals"] = i == 0 ? 0 : 3; + arr.push_back(o); + } + check(PayoutTestWriteFile(payingPath, arr.dump(2)), + "wrote a paying file containing the duplicate the old build made"); + LoadPaying(); + std::lock_guard lk(gPayoutExecMutex); + check(gPaying.size() == 1, + "loading collapses a duplicate in-flight record from disk"); + check(gPaying.size() == 1 && gPaying[0].retries == 7 && gPaying[0].deferrals == 3, + "collapsing merges the attempt counts upwards, not resetting them"); + } + + // -- 3. State updates address a slot, not a value ----------------------- + // + // Build the exact pathology the old code produced -- two records equal in + // every field -- and prove the update lands on the one asked for. Under + // value matching the first record absorbed everything and the second was + // untouchable. + { + PayoutTestResetState(); + PayingRecord a; + a.blockHeight = 4242; a.blockHash = 0; a.matureAtHeight = 4242; + a.address = addrA; a.amount = 10 * COIN; + PayingRecord b = a; + { + std::lock_guard lk(gPayoutExecMutex); + a.id = ++gPayingNextId; + b.id = ++gPayingNextId; + gPaying.push_back(a); + gPaying.push_back(b); + } + PayingRecord upd = b; + upd.retries = 5; + upd.retryAfter = 1234567; + ReschedulePayment(upd); + { + std::lock_guard lk(gPayoutExecMutex); + check(gPaying.size() == 2 && + gPaying[0].retries == 0 && gPaying[1].retries == 5, + "rescheduling updates the record it was given, not the first" + " one that looks like it"); + } + ForgetPayment(a); + { + std::lock_guard lk(gPayoutExecMutex); + check(gPaying.size() == 1 && gPaying[0].id == b.id, + "forgetting erases the record it was given, not the first" + " one that looks like it"); + } + } + + // -- 4. retryAfter survives a restart ------------------------------------ + { + PayoutTestResetState(); + remove(payingPath.c_str()); + PayingRecord r; + r.blockHeight = 77; r.blockHash = 0; r.matureAtHeight = 77; + r.address = addrA; r.amount = 3 * COIN; + r.retryAfter = 1700000000LL; + r.deferrals = 9; + VersionedBlob snap; + { + std::lock_guard lk(gPayoutExecMutex); + r.id = ++gPayingNextId; + gPaying.push_back(r); + snap = SerializePayingLocked(); + } + PublishPaying(snap); + PayoutTestResetState(); + LoadPaying(); + std::lock_guard lk(gPayoutExecMutex); + check(gPaying.size() == 1 && gPaying[0].retryAfter == 1700000000LL && + gPaying[0].deferrals == 9, + "the retry clock that is actually used survives a restart"); + } + + // -- 5. The ledger file cannot be published from a stale snapshot -------- + { + PayoutTestResetState(); + remove(ledgerPath.c_str()); + + PendingPayout one; + one.blockHeight = 100; one.blockHash = 0; one.matureAtHeight = 100; + one.amounts[addrA] = 1 * COIN; + VersionedBlob older, newer; + { + std::lock_guard lk(gPayoutLedgerMutex); + gPendingPayouts.push_back(one); + older = SerializePendingPayoutsLocked(); + PendingPayout two; + two.blockHeight = 200; two.blockHash = 0; two.matureAtHeight = 200; + two.amounts[addrB] = 2 * COIN; + gPendingPayouts.push_back(two); + newer = SerializePendingPayoutsLocked(); + } + check(newer.seq > older.seq, + "a later serialisation carries a strictly higher version"); + + // The order the race produced: the payout thread's older image + // arriving after the event loop's newer one. + PublishPendingPayouts(newer); + PublishPendingPayouts(older); + std::string onDisk; + check(PayoutTestReadFile(ledgerPath, onDisk) && onDisk == newer.blob, + "an older snapshot cannot overwrite a newer published ledger"); + + // And the ordinary direction still works. + VersionedBlob newest; + { + std::lock_guard lk(gPayoutLedgerMutex); + gPendingPayouts.clear(); + newest = SerializePendingPayoutsLocked(); + } + PublishPendingPayouts(newest); + check(PayoutTestReadFile(ledgerPath, onDisk) && onDisk == newest.blob, + "a newer snapshot does replace the published ledger"); + } + + // -- 6. Concurrent publishers cannot interleave a file ------------------- + // + // Two threads renaming the same ".tmp" over the same target was the + // original defect. The images are made big so a torn write shows up as + // unparseable JSON rather than by luck. + { + const std::string racePath = dir + "/publish_race_selftest.json"; + std::mutex raceMutex; + uint64 racePublished = 0; + + std::vector images; + for (int i = 0; i < 64; i++) { + nlohmann::json arr = nlohmann::json::array(); + for (int k = 0; k < 200; k++) { + nlohmann::json o; + o["image"] = i; + o["entry"] = k; + o["address"] = addrA; + o["amount"] = (int64)(k + 1) * COIN; + arr.push_back(o); + } + VersionedBlob v; + v.blob = arr.dump(2); + v.seq = (uint64)i + 1; + images.push_back(v); + } + + std::vector threads; + for (int t = 0; t < 4; t++) { + threads.push_back(std::thread([&, t]() { + for (size_t i = 0; i < images.size(); i++) { + size_t idx = (i * 7 + (size_t)t * 13) % images.size(); + PublishVersioned(racePath, images[idx], raceMutex, + racePublished, "publish race selftest"); + } + })); + } + for (std::thread& th : threads) + th.join(); + + std::string onDisk; + bool read = PayoutTestReadFile(racePath, onDisk); + bool parses = false; + if (read) { + try { parses = nlohmann::json::parse(onDisk).is_array(); } + catch (...) { parses = false; } + } + check(read && parses, + "four threads publishing the same file never leave it unparseable"); + check(read && onDisk == images.back().blob, + "the published file settles on the highest version, whatever the" + " completion order"); + remove(racePath.c_str()); + remove((racePath + ".tmp").c_str()); + } + + // -- 7. Grandfathered zero-hash rounds do not answer for each other ------ + { + uint256 h1 = 0, h2 = 0; + h1.SetHex("00000000000000000000000000000000000000000000000000000000000000aa"); + h2.SetHex("00000000000000000000000000000000000000000000000000000000000000bb"); + check(!SamePayoutRound(100, 0, 200, 0), + "two zero-hash entries at different heights are different rounds"); + check(SamePayoutRound(100, 0, 100, 0), + "two zero-hash entries at the same height are the same round"); + check(SamePayoutRound(100, h1, 200, h1), + "a real hash identifies the round on its own"); + check(!SamePayoutRound(100, h1, 100, h2), + "different hashes at one height are different rounds"); + check(!SamePayoutRound(100, h1, 100, 0), + "a grandfathered entry never matches a hash-keyed one"); + check(!PayoutRoundIdentifiable(0, 0) && PayoutRoundIdentifiable(0, h1) && + PayoutRoundIdentifiable(7, 0), + "an entry with neither hash nor height is not adjudicable"); + + // End to end: recording a payment against one grandfathered round must + // not settle the other's debt. + PayoutTestResetState(); + remove(ledgerPath.c_str()); + { + std::lock_guard lk(gPayoutLedgerMutex); + PendingPayout first; + first.blockHeight = 100; first.blockHash = 0; first.matureAtHeight = 100; + first.amounts[addrA] = 1 * COIN; + PendingPayout second; + second.blockHeight = 200; second.blockHash = 0; second.matureAtHeight = 200; + second.amounts[addrA] = 1 * COIN; + gPendingPayouts.push_back(first); + gPendingPayouts.push_back(second); + } + PayingRecord r; + r.blockHeight = 200; r.blockHash = 0; r.matureAtHeight = 200; + r.address = addrA; r.amount = 1 * COIN; + RecordPayoutTxid(r, "txid-for-the-second-round"); + { + std::lock_guard lk(gPayoutLedgerMutex); + // The second round was fully paid and retired; the first is + // untouched and still owed. + check(gPendingPayouts.size() == 1 && + gPendingPayouts[0].blockHeight == 100 && + gPendingPayouts[0].paidTxids.empty(), + "a txid recorded against one zero-hash round leaves the other" + " still owed"); + } + } + + // -- 8. A payment with only a legacy comment is recognised as paid ------- + { + PayoutTestResetState(); + const int height = 8080; + const int64 amount = 7 * COIN; + const std::string id = PayoutIdForBlock(height, 0); + const std::string comment = PayoutComment(height); + std::set none; + std::string found; + + check(FindRecordedPoolPayout(id, height, addrA, amount, none, found) + == PAYOUT_PROBE_NONE, + "an empty wallet is not evidence that anything was paid"); + + const std::string legacyTxid = + PayoutTestAddWalletTx(comment, addrA, amount, 1, ""); + check(FindRecordedPoolPayout(id, height, addrA, amount, none, found) + == PAYOUT_PROBE_LEGACY && found == legacyTxid, + "a payment carrying only a pre-upgrade comment is recognised as" + " already paid"); + + check(FindRecordedPoolPayout(id, height, addrB, amount, none, found) + == PAYOUT_PROBE_NONE, + "the legacy match requires the output to pay THIS recipient"); + check(FindRecordedPoolPayout(id, height, addrA, amount + COIN, none, found) + == PAYOUT_PROBE_NONE, + "the legacy match requires the output to pay exactly this amount"); + check(FindRecordedPoolPayout(id, height + 1, addrA, amount, none, found) + == PAYOUT_PROBE_NONE, + "the legacy match requires the comment for this block height"); + + std::set spent; + spent.insert(legacyTxid); + check(FindRecordedPoolPayout(id, height, addrA, amount, spent, found) + == PAYOUT_PROBE_NONE, + "one legacy transaction cannot settle a second debt"); + + // A transaction written by THIS build carries pool_payout_id, and must + // never be offered as legacy evidence for a different round -- that is + // exactly what the structured metadata exists to prevent. + const std::string otherId = PayoutIdForBlock(height, 0) + "-other"; + PayoutTestAddWalletTx(comment, addrB, amount, 2, otherId); + check(FindRecordedPoolPayout(id, height, addrB, amount, none, found) + == PAYOUT_PROBE_NONE, + "another round's structured payment is not legacy evidence for" + " this one"); + + // The structured path still wins where it applies. + const std::string structuredTxid = + PayoutTestAddWalletTx(comment, addrB, amount, 3, id); + check(FindRecordedPoolPayout(id, height, addrB, amount, none, found) + == PAYOUT_PROBE_STRUCTURED && found == structuredTxid, + "structured payout metadata is matched in preference to a comment"); + } + + // -- 9. On-disk compatibility with the live production ledger ------------ + { + PayoutTestResetState(); + // Exactly the shape of the file on the pool node: amounts, blockHash, + // blockHeight, matureAtHeight, and nothing else. + const char* live = + "[{\"amounts\":{\"%s\":1234500000}," + "\"blockHash\":\"00000000000000000000000000000000000000000000000000000000000000cd\"," + "\"blockHeight\":1000," + "\"matureAtHeight\":1120}]"; + check(PayoutTestWriteFile(ledgerPath, strprintf(live, addrA.c_str())), + "wrote a ledger file in the live production shape"); + LoadPendingPayouts(); + { + std::lock_guard lk(gPayoutLedgerMutex); + check(gPendingPayouts.size() == 1 && + gPendingPayouts[0].blockHeight == 1000 && + gPendingPayouts[0].matureAtHeight == 1120 && + gPendingPayouts[0].blockHash != 0 && + gPendingPayouts[0].amounts.size() == 1 && + gPendingPayouts[0].amounts[addrA] == 1234500000LL, + "the live production pending_payouts.json still loads"); + } + + // And the shape the merged build writes, retryAfter included, which + // this build no longer writes but must still read past. + PayoutTestResetState(); + const char* merged = + "[{\"amounts\":{\"%s\":100000000}," + "\"blockHash\":\"00000000000000000000000000000000000000000000000000000000000000ef\"," + "\"blockHeight\":2000,\"matureAtHeight\":2120,\"retryAfter\":0," + "\"paidTxids\":{\"%s\":\"abc123\"}}]"; + check(PayoutTestWriteFile(ledgerPath, + strprintf(merged, addrA.c_str(), addrA.c_str())), + "wrote a ledger file in the merged build's shape"); + LoadPendingPayouts(); + { + std::lock_guard lk(gPayoutLedgerMutex); + check(gPendingPayouts.size() == 1 && + gPendingPayouts[0].blockHeight == 2000 && + gPendingPayouts[0].paidTxids.size() == 1 && + gPendingPayouts[0].paidTxids[addrA] == "abc123", + "a ledger file carrying retryAfter and paidTxids still loads"); + } + + // A round trip through this build must not lose anything either. + VersionedBlob snap; + { + std::lock_guard lk(gPayoutLedgerMutex); + snap = SerializePendingPayoutsLocked(); + } + PublishPendingPayouts(snap); + PayoutTestResetState(); + LoadPendingPayouts(); + { + std::lock_guard lk(gPayoutLedgerMutex); + check(gPendingPayouts.size() == 1 && + gPendingPayouts[0].blockHeight == 2000 && + gPendingPayouts[0].matureAtHeight == 2120 && + gPendingPayouts[0].paidTxids[addrA] == "abc123", + "what this build writes, this build reads back unchanged"); + } + } + + // -- 10. A forfeit is published, not just dropped ------------------------ + { + PayoutTestResetState(); + remove(ledgerPath.c_str()); + const std::string roundsPath = dir + "/pool_rounds_selftest.json"; + const std::string savedRoundsFile = strPoolRoundsFile; + strPoolRoundsFile = roundsPath; + remove(roundsPath.c_str()); + + const int height = 606; + PoolRoundProof round; + round.id = PayoutIdForBlock(height, 0); + round.blockHeight = height; + round.matureAtHeight = height; + round.payouts[addrA] = 5 * COIN; + round.payouts[addrB] = 6 * COIN; + RecordPoolRound(round); + + { + std::lock_guard lk(gPayoutLedgerMutex); + PendingPayout pp; + pp.blockHeight = height; pp.blockHash = 0; pp.matureAtHeight = height; + pp.amounts[addrA] = 5 * COIN; + pp.amounts[addrB] = 6 * COIN; + gPendingPayouts.push_back(pp); + } + + PayingRecord r; + r.blockHeight = height; r.blockHash = 0; r.matureAtHeight = height; + r.address = addrA; r.amount = 5 * COIN; + ForfeitPayoutRecipient(r, "selftest reason"); + + { + std::lock_guard lk(gPayoutLedgerMutex); + check(gPendingPayouts.size() == 1 && + gPendingPayouts[0].amounts.count(addrA) == 0 && + gPendingPayouts[0].amounts.count(addrB) == 1, + "a forfeit removes only that recipient from the ledger entry"); + } + + std::string onDisk; + bool proofOk = false; + if (PayoutTestReadFile(roundsPath, onDisk)) { + try { + nlohmann::json root = nlohmann::json::parse(onDisk); + const nlohmann::json& p = root["rounds"][0]["payouts"]; + proofOk = p[addrA].value("forfeited", false) && + p[addrA].value("forfeitReason", "") == "selftest reason" && + p[addrA]["txid"].is_null() && + !p[addrB].contains("forfeited"); + } catch (...) { + proofOk = false; + } + } + check(proofOk, + "the published round proof records the forfeit instead of" + " advertising the address as still owed"); + + // And it survives a reload of the proof file. + { + std::lock_guard lk(gPoolRoundsMutex); + gPoolRounds.clear(); + } + LoadPoolRounds(); + { + std::lock_guard lk(gPoolRoundsMutex); + check(gPoolRounds.size() == 1 && + gPoolRounds[0].forfeited.count(addrA) == 1 && + gPoolRounds[0].forfeited[addrA] == "selftest reason", + "the forfeit is read back from pool_rounds.json"); + } + + remove(roundsPath.c_str()); + remove((roundsPath + ".tmp").c_str()); + strPoolRoundsFile = savedRoundsFile; + } + + // -- clean up ------------------------------------------------------------ + PayoutTestResetState(); + CRITICAL_BLOCK(cs_mapWallet) + mapWallet.clear(); + remove(ledgerPath.c_str()); + remove((ledgerPath + ".tmp").c_str()); + remove(payingPath.c_str()); + remove((payingPath + ".tmp").c_str()); + remove((dir + "/pool_status.json").c_str()); + remove((dir + "/pool_rounds.json").c_str()); +#ifdef _WIN32 + _rmdir(dir.c_str()); +#else + rmdir(dir.c_str()); +#endif + strSetDataDir = savedDataDir; + nBestHeight = savedBestHeight; + + fprintf(stdout, "%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + int RunPoolStratumSelfTest() { int nFail = 0; diff --git a/src/selftest.cpp b/src/selftest.cpp index eed6779..034e1f7 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -13,6 +13,7 @@ #include "walletcmd.h" extern int RunPoolStratumSelfTest(); +extern int RunPayoutLedgerSelfTest(); // Test results go to the terminal, not to debug.log. // @@ -1573,12 +1574,14 @@ int RunSelfTest(const std::string& name) return RunConsensusLimitsSelfTest(); if (name == "pool-stratum") return RunPoolStratumSelfTest(); + if (name == "payout-ledger") + return RunPayoutLedgerSelfTest(); if (name == "parse-money") return RunParseMoneySelfTest(); if (name == "socks5-proxy") return RunSocks5ProxySelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits, pool-stratum, payout-ledger, parse-money, socks5-proxy\n"); return 1; } -- cgit v1.2.3 From 1017644bc8d4bf183086ffb816c5e3f18068568e Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Tue, 1 Sep 2026 17:16:04 +0000 Subject: wallet: refuse to mint a wallet a headless node never asked for v1.2.20 makes SQLite the default backend for a datadir that holds no wallet. On a desktop that is a first run. On a headless node it is usually a fault -- the datadir is not mounted, or wallet.dat was caught mid-rename by a backup -- and the new default turns that fault into a silent loss of custody rather than a loud failure. The damage is not the empty wallet, it is the marker. Creating the wallet also writes /wallet-backend = sqlite, and every later start reads that marker first: putting the real wallet.dat back no longer does anything, because the node stays on the empty SQLite wallet it made. 1.2.19 had the same fail-open on a missing wallet, but it created a wallet.dat, and restoring the backup over it recovered the funds. That runbook stops working here. So refuse when the startup is headless and no wallet of either kind is present, and say which flag makes it deliberate: /createwallet to start a new one, or /walletbackend=bdb for the Berkeley DB path. A node that meant to create a wallet says so; a node that lost its datadir gets an error instead of a fresh identity and a re-paid ledger. Upstream keeps the old behaviour; this is a fork divergence, and the three findings that would gate enabling the SQLite backend at all -- the non-atomic backend marker, WriteHDMaster's connection-wide transaction, and the encrypt-swap crash window -- remain open against upstream. --- src/main_gui.cpp | 29 +++++++++++++++++++++++++++++ src/selftest.cpp | 26 +++++++++++++++++++++----- 2 files changed, 50 insertions(+), 5 deletions(-) (limited to 'src/selftest.cpp') diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 2743429..25cde1c 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -196,6 +196,9 @@ static void PrintUsage() printf(" /walletsqlite=FILE (stage-load a SQLite wallet export, then exit)\n"); printf(" /walletbackend=sqlite (opt-in: run the node on /wallet.sqlite\n"); printf(" instead of wallet.dat; export one first)\n"); + printf(" /createwallet (headless only: permit creating a new wallet in a\n"); + printf(" datadir that has none -- otherwise refused, since\n"); + printf(" a missing wallet is usually a mount/backup fault)\n"); printf(" /rescan (walk the chain for coins this wallet owns\n"); printf(" but never recorded, then exit)\n"); printf("\n"); @@ -835,6 +838,32 @@ int main(int argc, char* argv[]) else { fUseSQLite = true; fCreateSQLite = true; } } + // A headless node whose datadir holds no wallet of either kind is + // far more often an operations fault -- datadir not mounted, or + // wallet.dat caught mid-rename by a backup -- than a first run. + // Creating one here is not the harmless default it looks like: + // success also writes the backend marker, and from then on + // restoring the real wallet.dat no longer takes effect, because the + // marker keeps the node on the empty wallet it just made. Under + // 1.2.19 the same fault created a wallet.dat that a restore + // overwrote, so operators could recover by putting the file back. + // Refuse instead, and name the flag that makes it deliberate. + if (fCreateSQLite && fHeadlessStartup && + !arg(argc, argv, "/createwallet") && !arg(argc, argv, "-createwallet")) + { + AttachTerminal(); + fprintf(stderr, + "No wallet found in %s\n" + "Refusing to create one. On a headless node an absent wallet is\n" + "usually a mount or backup fault, and creating one would record a\n" + "backend marker that makes restoring the real wallet a no-op.\n" + "Pass -createwallet to start a new one deliberately, or\n" + "-walletbackend=bdb to run on a Berkeley DB wallet.dat.\n", + GetAppDir().c_str()); + fflush(stderr); + return 1; + } + if (fUseSQLite) { AttachTerminal(); diff --git a/src/selftest.cpp b/src/selftest.cpp index 2ada8f3..c62d70f 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -2580,7 +2580,11 @@ static int RunWalletBackendDefaultSelfTest() try { - // 1. Fresh datadir, no flag -> new wallet.sqlite, marker "sqlite", no wallet.dat. + // 1. Fresh datadir, no flag -> this fork refuses, where upstream creates. + // An empty datadir on a headless node is far more often a mount or + // backup fault than a first run, and creating a wallet also pins the + // backend marker, after which restoring the real wallet is a no-op. + // Nothing may be left behind: no wallet of either kind, no marker. string d1 = tmp + "/fresh"; nFail += Check(MakeDirLocal(d1), "fresh scenario dir can be created") ? 0 : 1; vector a1; @@ -2588,19 +2592,31 @@ static int RunWalletBackendDefaultSelfTest() a1.push_back("-nomanagedtor"); a1.push_back("-nogui"); a1.push_back("-newaddress"); - int r1 = RunBitflashChild(strExe, a1); + int r0 = RunBitflashChild(strExe, a1); + nFail += Check(r0 != 0 && + !FileExists((d1 + "/wallet.sqlite").c_str()) && + !FileExists((d1 + "/wallet.dat").c_str()) && + !FileExists((d1 + "/wallet-backend").c_str()), + "a flagless headless start refuses to create a wallet") ? 0 : 1; + + // 1a. -createwallet says it was meant: new wallet.sqlite, marker + // "sqlite", no wallet.dat. + vector a1c = a1; + a1c.push_back("-createwallet"); + int r1 = RunBitflashChild(strExe, a1c); nFail += Check(r1 == 0 && FileExists((d1 + "/wallet.sqlite").c_str()) && !FileExists((d1 + "/wallet.dat").c_str()) && FileContainsText(d1 + "/wallet-backend", "sqlite"), - "a fresh install starts on SQLite (new default) and records the marker") ? 0 : 1; + "-createwallet starts a fresh install on SQLite and records the marker") ? 0 : 1; - // 1b. Second run reuses it, still SQLite, still no wallet.dat. + // 1b. Second run reuses it without the flag -- the refusal only guards + // creation, so an existing wallet still starts flagless. int r1b = RunBitflashChild(strExe, a1); nFail += Check(r1b == 0 && FileExists((d1 + "/wallet.sqlite").c_str()) && !FileExists((d1 + "/wallet.dat").c_str()), - "the recorded SQLite choice survives a restart") ? 0 : 1; + "the recorded SQLite choice survives a flagless restart") ? 0 : 1; // 2. Existing wallet.dat, no flag -> stays on Berkeley DB. string d2 = tmp + "/existing-bdb"; -- cgit v1.2.3 From ff6af901eaab61b6dbba41bb80f7bb2f40980880 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Wed, 2 Sep 2026 14:23:50 +0000 Subject: selftest: cover the assembly half of the block limits consensus-limits only ever asked what CheckBlock() refuses. Nothing asked what the assemblers produce, which is why a miner able to overshoot the cap by an entire mempool sat here unnoticed through both the deployed and candidate builds. Adds the other half: a template built from a 3 MB mempool stays inside the budget and passes CheckSizeLimits(); a transaction too big for the budget is skipped while the ones that fit are still taken; sigop-heavy transactions stop selection at MAX_BLOCK_SIGOPS while the block is still small. Plus the rule's edges, including that sigops exactly on the limit fit -- CheckBlock() fails on `>`, so assembly must accept what validation accepts. The test drives BlockTemplateFits the way the assemblers drive it rather than calling them: BitcoinMiner needs a CTxDB, a wallet key and cs_main, and RebuildJob is static in rpc.cpp. What that proves is the shared rule and the property it exists to give, which is where the defect was -- the budget was right and the enforcement was not. Checked against the bug: with the fit test neutered, eight of these fail, including both templates that CheckBlock() would have rejected. With the sigop comparison stricter than CheckBlock's by one, the boundary case fails. --- src/selftest.cpp | 157 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) (limited to 'src/selftest.cpp') diff --git a/src/selftest.cpp b/src/selftest.cpp index 9f7106f..d11fdd8 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1235,6 +1235,74 @@ static CBlock MakeSizedConsensusBlock(size_t nScriptBytes) return block; } +// A distinct transaction of a chosen weight and verification cost. nUnique +// only keeps the txids apart, so a mempool built from these is not a pile of +// duplicates a block could never carry anyway. +static CTransaction MakeSizedTx(size_t nPadBytes, unsigned int nSigOps, int nUnique) +{ + CTransaction tx; + tx.vin.push_back(CTxIn(COutPoint(), CScript() << (int64)nUnique << 1)); + tx.vout.push_back(CTxOut(0, CScript())); + for (unsigned int i = 0; i < nSigOps; i++) + tx.vout[0].scriptPubKey << OP_CHECKSIG; + tx.vout[0].scriptPubKey.insert(tx.vout[0].scriptPubKey.end(), nPadBytes, 0); + return tx; +} + +static CBlock MakeCoinbaseOnlyTemplate() +{ + CTransaction txNew; + txNew.vin.resize(1); + txNew.vin[0].prevout.SetNull(); + txNew.vin[0].scriptSig << 1 << 1 << 1; + txNew.vout.resize(1); + txNew.vout[0].scriptPubKey << OP_CHECKSIG; + + CBlock block; + block.vtx.push_back(txNew); + block.nVersion = 1; + block.nTime = GetAdjustedTime(); + block.nBits = bnProofOfWorkLimit.GetCompact(); + block.nNonce = 0; + return block; +} + +// The sum CheckBlock() takes, spelled the same way. +static unsigned int BlockSigOpCount(const CBlock& block) +{ + unsigned int n = 0; + for (int i = 0; i < (int)block.vtx.size(); i++) + n += block.vtx[i].GetSigOpCount(); + return n; +} + +// Drive the selection rule the way both assemblers drive it: ask +// BlockTemplateFits before appending, skip what does not fit, never stop early +// on one that does not. +// +// This mirrors the loops in BitcoinMiner() and RebuildJob() rather than calling +// them -- one needs a CTxDB, a wallet key and cs_main, the other is static in +// rpc.cpp, so neither is reachable from here. What that leaves proven is the +// rule the two of them share and the property it is meant to guarantee, which +// is where the defect actually lived: the budget was right and the enforcement +// was not. +static void SelectIntoTemplate(CBlock& block, const vector& vMempool) +{ + unsigned int nBlockSize = ::GetSerializeSize(block, SER_DISK); + unsigned int nBlockSigOps = block.vtx[0].GetSigOpCount(); + for (int i = 0; i < (int)vMempool.size(); i++) + { + const CTransaction& tx = vMempool[i]; + unsigned int nTxSize = ::GetSerializeSize(tx, SER_NETWORK); + unsigned int nTxSigOps = tx.GetSigOpCount(); + if (!BlockTemplateFits(nBlockSize, nBlockSigOps, nTxSize, nTxSigOps)) + continue; + block.vtx.push_back(tx); + nBlockSize += nTxSize; + nBlockSigOps += nTxSigOps; + } +} + static int RunConsensusLimitsSelfTest() { fflush(stdout); @@ -1258,6 +1326,95 @@ static int RunConsensusLimitsSelfTest() "the test block sits between 1 MB and the old 32 MB cap") ? 0 : 1; nFail += Check(!oversized.CheckSizeLimits(), "a block above 1 MB is outside the consensus size limit") ? 0 : 1; + + // The other half of the cap. Everything above asks what validation + // refuses; nothing asked what assembly produces, which is why a miner + // that could overshoot the cap by an entire mempool went unnoticed. + nFail += Check(MAX_BLOCK_SIZE_GEN < MAX_BLOCK_SIZE, + "the assembly budget stays under the consensus cap") ? 0 : 1; + + // The rule itself, at its edges. The sigop comparison has to match + // CheckBlock(), which fails on `> MAX_BLOCK_SIGOPS` and so accepts a + // block sitting exactly on the limit. + nFail += Check(BlockTemplateFits(MAX_BLOCK_SIZE_GEN - 100, 0, 100, 0), + "a transaction that exactly fills the size budget fits") ? 0 : 1; + nFail += Check(!BlockTemplateFits(MAX_BLOCK_SIZE_GEN - 100, 0, 101, 0), + "one byte past the size budget does not fit") ? 0 : 1; + nFail += Check(BlockTemplateFits(0, 0, 0, MAX_BLOCK_SIGOPS), + "sigops exactly on the limit fit, as CheckBlock() allows") ? 0 : 1; + nFail += Check(!BlockTemplateFits(0, 0, 0, MAX_BLOCK_SIGOPS + 1), + "one sigop past the limit does not fit") ? 0 : 1; + + // A mempool several times the consensus cap, in ordinary-sized + // transactions. The old miner tested its budget only on the outer + // loop, so a single pass appended all of this. + { + CBlock block = MakeCoinbaseOnlyTemplate(); + vector vMempool; + for (int i = 0; i < 300; i++) + vMempool.push_back(MakeSizedTx(10000, 1, i)); + SelectIntoTemplate(block, vMempool); + block.hashMerkleRoot = block.BuildMerkleTree(); + + nFail += Check(block.vtx.size() > 1, + "a full mempool still fills a template") ? 0 : 1; + nFail += Check(::GetSerializeSize(block, SER_DISK) <= MAX_BLOCK_SIZE_GEN, + "a template built from a 3 MB mempool respects the budget") ? 0 : 1; + nFail += Check(block.CheckSizeLimits(), + "that template is one CheckBlock() would accept on size") ? 0 : 1; + } + + // One transaction too big for the budget. Nothing on the path into the + // mempool caps transaction size, so this is a transaction a peer can + // simply send. It must be skipped and the rest still taken -- skipped, + // not stopped on. + { + CBlock block = MakeCoinbaseOnlyTemplate(); + vector vMempool; + vMempool.push_back(MakeSizedTx(1500000, 1, 9999)); + for (int i = 0; i < 10; i++) + vMempool.push_back(MakeSizedTx(1000, 1, i)); + + unsigned int nHugeSize = ::GetSerializeSize(vMempool[0], SER_NETWORK); + nFail += Check(nHugeSize > MAX_BLOCK_SIZE && nHugeSize <= MAX_SIZE, + "the outsized transaction is one the mempool would hold") ? 0 : 1; + + SelectIntoTemplate(block, vMempool); + block.hashMerkleRoot = block.BuildMerkleTree(); + + bool fTookHuge = false; + for (int i = 0; i < (int)block.vtx.size(); i++) + if (block.vtx[i].GetHash() == vMempool[0].GetHash()) + fTookHuge = true; + + nFail += Check(!fTookHuge, + "a transaction larger than the budget is left out") ? 0 : 1; + nFail += Check(block.vtx.size() == 11, + "the ten that do fit are taken anyway") ? 0 : 1; + nFail += Check(block.CheckSizeLimits(), + "one outsized transaction cannot invalidate a template") ? 0 : 1; + } + + // Transactions that are cheap in bytes and expensive to verify. Size + // alone does not bound what a block costs, and CheckBlock() refuses on + // the count regardless of how small the block is. + { + CBlock block = MakeCoinbaseOnlyTemplate(); + vector vMempool; + for (int i = 0; i < 100; i++) + vMempool.push_back(MakeSizedTx(0, 1000, i)); + SelectIntoTemplate(block, vMempool); + block.hashMerkleRoot = block.BuildMerkleTree(); + + nFail += Check(block.vtx.size() > 1, + "sigop-heavy transactions are still selected") ? 0 : 1; + nFail += Check(BlockSigOpCount(block) <= MAX_BLOCK_SIGOPS, + "selection stops at the sigop cap CheckBlock() enforces") ? 0 : 1; + nFail += Check(::GetSerializeSize(block, SER_DISK) < MAX_BLOCK_SIZE_GEN, + "and stops on sigops while still well inside the size budget") ? 0 : 1; + nFail += Check(block.CheckSizeLimits(), + "the sigop-bounded template is within the size limit too") ? 0 : 1; + } } catch (const std::exception& e) { -- cgit v1.2.3