From b3a57b07adb485143873f22da4ce177e4149982f Mon Sep 17 00:00:00 2001 From: DanS Date: Mon, 12 Oct 2026 13:22:00 -0500 Subject: [PATCH] test(fake-daemon): add slow rescan and key-import methods fake_dragonxd now answers rescan, importprivkey, z_importkey and z_importviewingkey, so the GUI's long wallet RPCs can be reproduced without a real node. - Each one holds cs_main for FAKE_RESCAN_SECS (default 0). The lock-free getrescaninfo reports the scan while it runs, and, like v1.3.0, the scan stops early when a shutdown is requested. - Argument checks, error codes and return values follow dragonxd (z_importkey returns null, importprivkey the t-address, the default "whenkeyisnew" skips a known key, z_importviewingkey needs its zaddr). Keys are only prefix-checked and never stored or logged; an imported spending key gets a made-up address that the address lists then show. - FAKE_ENCRYPTED=1 / FAKE_WALLET_PASSPHRASE: an encrypted wallet that starts locked, with walletpassphrase (-14 on a wrong passphrase), walletpassphrasechange, walletlock and getwalletinfo unlocked_until; the imports answer -13 while it is locked. As in dragonxd, the right passphrase with a non-integer timeout unlocks first and only then fails (-1): the wallet stays unlocked with no relock timer of its own. A timer pending from an earlier unlock keeps running; otherwise only walletlock locks it again. Without FAKE_ENCRYPTED, those three methods answer -15 like dragonxd. - FAKE_METHODS_ALL call events show keys and passphrases as . A plain run is unchanged: the same 63 GUI calls before and after, and identical replies to every one of them. Co-Authored-By: Claude Opus 5.5 --- tools/fake_dragonxd/README.md | 24 +- tools/fake_dragonxd/fake_dragonxd.cpp | 302 +++++++++++++++++++++++++- 2 files changed, 323 insertions(+), 3 deletions(-) diff --git a/tools/fake_dragonxd/README.md b/tools/fake_dragonxd/README.md index 41101d4..ed69537 100644 --- a/tools/fake_dragonxd/README.md +++ b/tools/fake_dragonxd/README.md @@ -37,6 +37,22 @@ What it copies from the real daemon (DragonX v1.3.0): Its replies are canned: `getinfo` reports version `1030050`, the balance is fixed, and there are two peers on TEST-NET addresses. Unknown methods get the real `-32601 Method not found` (HTTP 404). +Rescan, key imports and wallet encryption follow dragonxd's argument checks, error codes and return values: + +- `rescan`, `importprivkey`, `z_importkey` and `z_importviewingkey` run a wallet scan that holds `cs_main` + for `FAKE_RESCAN_SECS`. While it runs, `getrescaninfo` (lock-free) reports `rescanning` with its progress, + and a `Still rescanning` line is logged once a minute. Like v1.3.0, the scan stops early when a shutdown + is requested. `z_importviewingkey` needs its 4th argument (the zaddr), as in dragonxd. +- Keys are only prefix-checked: a 51/52-character base58 WIF, `secret-extended-key-main1…` or `zivks1…`. + Only a fingerprint is kept; the keys are never stored or logged. An imported spending key gets a + made-up address, which `z_listaddresses`/`getaddressesbyaccount` list from then on. A second import of + the same key with the default `whenkeyisnew` returns at once, with no scan. +- With `FAKE_ENCRYPTED=1` the wallet is encrypted and starts locked. `walletpassphrase` (a wrong passphrase + gives `-14`), `walletpassphrasechange` and `walletlock` work, `getwalletinfo` carries `unlocked_until`, + and the imports answer `-13` while the wallet is locked. As in dragonxd, the right passphrase with a + non-integer timeout unlocks first and then fails (`-1`), so the wallet stays unlocked with no relock. + Without it those three methods answer `-15` ("running with an unencrypted wallet"), like dragonxd. + ## Events file Everything that matters for a test is appended to `/fake_dragonxd.events`, one @@ -54,6 +70,10 @@ Everything that matters for a test is appended to `/fake_dragonxd.event | `http-interrupted`, `shutdown-begin` | shutdown progress | | `stats ...` | counters, every 30 s when they change | | `methods ...` | the 10 most-requested JSON-RPC methods with counts (with `stats`, and at exit): what the GUI is polling | +| `rescan-begin m= from=`, `rescan-end m= ms=` | a wallet scan (`rescan` or an import) took and released `cs_main` | +| `rescan-interrupted m= at= ms=` | a shutdown request ended the scan early | +| `import m= addr= rescan=` | a key was imported (`known=1`: it was already in the wallet) | +| `walletpassphrase result=`, `walletpassphrasechange result=`, `walletlock` | `FAKE_ENCRYPTED` wallet calls (`ok` or `-14`) | | `clean-exit ...` | **graceful exit**, with final counters. If this is missing, the process was killed. | ## Build @@ -124,11 +144,13 @@ inherits the GUI's environment. | `FAKE_MERGE_SECS` | 6 | how long a `z_mergetoaddress` operation runs before it succeeds | | `FAKE_MERGE_SERIAL` | 0 | `1` = ops run one at a time, like dragonxd's single async worker: a new op is `queued` until the previous one finishes | | `FAKE_MERGE_FAIL` | (off) | `syncing:N` (the Nth and later merges fail with the THROW_IF_SYNCING text), `lockedkey:N` (-8 "Could not find spending key…" after the scan), `opfail:N` (the Nth and later ops end `failed`, their notes unlocked) | +| `FAKE_RESCAN_SECS` | 0 | `rescan` and the key imports (`importprivkey`, `z_importkey`, `z_importviewingkey`) hold cs_main this long; `rescan-*` events | +| `FAKE_ENCRYPTED` / `FAKE_WALLET_PASSPHRASE` | 0 / `fake passphrase one` | `1` = an encrypted wallet, locked at start, with this passphrase | | `FAKE_SCAN_SECS` | 0 | the GUI's wallet scans (`z_listunspent`, `listunspent`, `z_gettotalbalance`, `z_listreceivedbyaddress`, `z_mergetoaddress`) hold cs_main this long; `scan-begin`/`scan-end` events | | `FAKE_TIP_UNKNOWN_SECS` | 0 | `longestchain` reads 0 this long after `Done Loading` (a node that hasn't seen an UpdateTip yet) | | `FAKE_LONGEST_AHEAD` / `FAKE_CATCHUP_SECS` | 0 / 0 | the network tip (`longestchain`, `headers`) is N blocks ahead, for that long after `Done Loading` (0 = for good) | | `FAKE_TXCOUNT` | 42 | `getwalletinfo` `txcount` (the GUI's connect gate waits for the tip on wallets of 500+) | -| `FAKE_METHODS_ALL` | 0 | `1` = one `call m= p=` event per request (the GUI's RPC cadence; the `methods` lines keep only the top 10 counts) | +| `FAKE_METHODS_ALL` | 0 | `1` = one `call m= p=` event per request (the GUI's RPC cadence; the `methods` lines keep only the top 10 counts). Keys and passphrases show as `` | | `FAKE_HEIGHT`, `FAKE_REBUILD_TOTAL`, `FAKE_NOTES`, `FAKE_VERSION` | 3327145, 62583, 13632, 1030050 | cosmetic numbers | ## Test cases diff --git a/tools/fake_dragonxd/fake_dragonxd.cpp b/tools/fake_dragonxd/fake_dragonxd.cpp index 824a778..c444beb 100644 --- a/tools/fake_dragonxd/fake_dragonxd.cpp +++ b/tools/fake_dragonxd/fake_dragonxd.cpp @@ -644,6 +644,281 @@ Canned noteWalletReply(const std::string& m, const json& params) return c; } +// --------------------------------------------------------------------------------------------- +// Rescan, key imports and wallet encryption (dragonxd's rpcdump.cpp / rpcwallet.cpp). walletReply runs +// under g_csMain (execOne); getrescaninfo and getwalletinfo read only the atomics. +// * rescan, importprivkey, z_importkey and z_importviewingkey: dragonxd's argument checks, then a +// ScanForWalletTransactions that holds cs_main for FAKE_RESCAN_SECS (0 = instant). The lock-free +// getrescaninfo reports it, it logs "Still rescanning" once a minute and, like v1.3.0, it stops +// between blocks once a shutdown is requested. Keys are only prefix-checked, never decoded, stored +// or logged; an imported key gets a derived fake address, and a key already imported isn't added again. +// * FAKE_ENCRYPTED=1: an encrypted wallet, locked at start, with the passphrase FAKE_WALLET_PASSPHRASE. +// walletpassphrase (-14 when wrong), walletpassphrasechange and walletlock work, getwalletinfo carries +// unlocked_until and the imports need the wallet unlocked (-13). Unencrypted, those three answer -15. +// Like dragonxd, the right passphrase with a non-integer timeout unlocks first and only then fails +// (-1), leaving the wallet unlocked with no relock timer of its own. +// --------------------------------------------------------------------------------------------- +double g_rescanSecs = 0.0; // FAKE_RESCAN_SECS +std::atomic g_rescanning{false}; +std::atomic g_rescanStart{0}, g_rescanHeight{0}; +std::vector g_importedKeys; // fingerprints of the imported keys +std::vector g_importedZ, g_importedT; // their addresses, listed with the wallet's own +bool g_walletEncrypted = false; // FAKE_ENCRYPTED=1 (set before any thread starts) +std::string g_walletPassphrase; // FAKE_WALLET_PASSPHRASE +std::atomic g_unlockedUntil{0}; // unix seconds; 0 = locked +std::atomic g_unlockedNoTimer{false}; // unlocked by a walletpassphrase whose timeout didn't parse + +const char* const kNotString = "JSON value is not a string as expected"; +const char* const kNotInteger = "JSON value is not an integer as expected"; +const char* const kBase58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +bool isWalletMethod(const std::string& m) +{ + return m == "rescan" || m == "importprivkey" || m == "z_importkey" || m == "z_importviewingkey" || + m == "walletpassphrase" || m == "walletpassphrasechange" || m == "walletlock"; +} + +// The wallet relocks when walletpassphrase's timeout runs out (dragonxd's RPCRunLater("lockwallet")). +long long unlockedUntilNow() +{ + const long long until = g_unlockedUntil.load(); + return (until != 0 && until <= unixMs() / 1000) ? 0 : until; +} + +// CWallet::IsLocked() == false. Separate from unlocked_until (nWalletUnlockTime), which an unlock with an +// unparsable timeout leaves as it was. +bool walletUnlockedNow() +{ + return unlockedUntilNow() != 0 || g_unlockedNoTimer.load(); +} + +std::uint64_t fingerprint(const std::string& s) // FNV-1a +{ + std::uint64_t h = 1469598103934665603ULL; + for (unsigned char ch : s) h = (h ^ ch) * 1099511628211ULL; + return h; +} + +std::string derivedAddress(const std::string& prefix, size_t length, std::uint64_t fp, const char* alphabet) +{ + std::string out = prefix; + const size_t n = std::strlen(alphabet); + for (std::uint64_t i = 0; out.size() < length; ++i) out.push_back(alphabet[splitmix64(fp + i) % n]); + return out; +} + +// ScanForWalletTransactions from height `from`, under the caller's cs_main. +void walletScan(const std::string& m, long from) +{ + const long tip = g_height.load(); + g_rescanStart = from; + g_rescanHeight = from; + g_rescanning = true; + const auto started = Clock::now(); + event("rescan-begin", "m=" + m + " from=" + std::to_string(from)); + auto lastLog = started; + bool interrupted = false; + for (;;) { + const double secs = std::chrono::duration(Clock::now() - started).count(); + const double frac = g_rescanSecs > 0.0 ? std::min(1.0, secs / g_rescanSecs) : 1.0; + g_rescanHeight = from + static_cast(static_cast(tip - from) * frac); + if (frac >= 1.0) break; + if (shutdownRequested()) { + interrupted = true; + break; + } + if (Clock::now() - lastLog >= 60s) { + lastLog = Clock::now(); + logf("Still rescanning. At block %ld. Progress=%f", g_rescanHeight.load(), + tip > 0 ? static_cast(g_rescanHeight.load()) / static_cast(tip) : 1.0); + } + std::this_thread::sleep_for(50ms); + } + const long long ms = std::chrono::duration_cast(Clock::now() - started).count(); + if (interrupted) { + logf("ScanForWalletTransactions: Rescan interrupted by shutdown request at block %ld", g_rescanHeight.load()); + event("rescan-interrupted", "m=" + m + " at=" + std::to_string(g_rescanHeight.load()) + " ms=" + std::to_string(ms)); + } else { + event("rescan-end", "m=" + m + " ms=" + std::to_string(ms)); + } + g_rescanning = false; +} + +// The answer for isWalletMethod(m) (the checks in dragonxd's order), or c.known = false. +Canned walletReply(const std::string& m, const json& params) +{ + Canned c; + if (!isWalletMethod(m)) { + c.known = false; + return c; + } + auto fail = [&c](int code, const std::string& msg) { + c.errCode = code; + c.errMsg = msg; + return c; + }; + auto str = [&](size_t i) { return params[i].get(); }; + const long tip = g_height.load(); + const bool locked = g_walletEncrypted && !walletUnlockedNow(); + const char* const kUnlockNeeded = "Error: Please enter the wallet passphrase with walletpassphrase first."; + c.result = nullptr; + + if (m == "walletpassphrase" || m == "walletpassphrasechange" || m == "walletlock") { + const size_t argc = m == "walletlock" ? 0 : 2; + const std::string usage = m == "walletlock" ? "walletlock" : m == "walletpassphrase" + ? "walletpassphrase \"passphrase\" timeout" + : "walletpassphrasechange \"oldpassphrase\" \"newpassphrase\""; + if (g_walletEncrypted && params.size() != argc) return fail(-1, usage); + if (!g_walletEncrypted) return fail(-15, "Error: running with an unencrypted wallet, but " + m + " was called."); + if (m == "walletlock") { + g_unlockedUntil = 0; + g_unlockedNoTimer = false; + event("walletlock", "result=ok"); + return c; + } + if (!params[0].is_string()) return fail(-1, kNotString); + if (m == "walletpassphrase") { + if (str(0).empty()) return fail(-1, usage); + if (str(0) != g_walletPassphrase) { + event("walletpassphrase", "result=-14"); + return fail(-14, "Error: The wallet passphrase entered was incorrect."); + } + // dragonxd unlocks before it reads the timeout (get_int64), so a bad one leaves the wallet + // unlocked with no new relock timer; a timer still pending from an earlier unlock keeps running. + if (!params[1].is_number_integer()) { + if (unlockedUntilNow() == 0) g_unlockedNoTimer = true; + return fail(-1, kNotInteger); + } + const long long timeout = params[1].get(); + g_unlockedUntil = unixMs() / 1000 + timeout; + g_unlockedNoTimer = false; // the new timer relocks it + event("walletpassphrase", "result=ok timeout=" + std::to_string(timeout)); + return c; + } + if (!params[1].is_string()) return fail(-1, kNotString); + if (str(0).empty() || str(1).empty()) return fail(-1, usage); + if (str(0) != g_walletPassphrase) { + event("walletpassphrasechange", "result=-14"); + return fail(-14, "Error: The wallet passphrase entered was incorrect."); + } + g_walletPassphrase = str(1); + event("walletpassphrasechange", "result=ok"); + return c; + } + + if (m == "rescan") { + if (params.size() > 1) return fail(-1, "rescan \"height\""); + long from = 0; + if (!params.empty()) { + if (!params[0].is_number_integer()) return fail(-1, kNotInteger); + from = params[0].get(); + } + if (from < 0 || from > tip) return fail(-8, "Block height out of range"); + logf("Rescanning from height=%ld", from); + walletScan(m, from); + logf("Done rescanning from height=%ld", from); + return c; + } + + if (m == "importprivkey") { + if (params.empty() || params.size() > 5) + return fail(-1, "importprivkey \"dragonxprivkey\" ( \"label\" rescan height secret_key)"); + if (locked) return fail(-13, kUnlockNeeded); + if (!params[0].is_string() || (params.size() > 1 && !params[1].is_string())) return fail(-1, kNotString); + bool rescan = true; + if (params.size() > 2) { + if (!params[2].is_boolean()) return fail(-1, "JSON value is not a boolean as expected"); + rescan = params[2].get(); + } + long from = 0; + if (rescan && params.size() == 4) { + if (!params[3].is_number_integer()) return fail(-1, kNotInteger); + from = params[3].get(); + } + if (from < 0 || from > tip) return fail(-4, "Rescan height is out of range."); + const std::string& key = params[0].get_ref(); + if ((key.size() != 51 && key.size() != 52) || key.find_first_not_of(kBase58) != std::string::npos) + return fail(-5, "Invalid private key encoding"); + const std::uint64_t fp = fingerprint("t:" + key); + const std::string addr = derivedAddress("RFakeImport", 34, fp, kBase58); + c.result = addr; + if (std::find(g_importedKeys.begin(), g_importedKeys.end(), fp) != g_importedKeys.end()) { + event("import", "m=" + m + " known=1 addr=" + addr); + return c; // "Don't throw error in case a key is already there": no rescan either + } + g_importedKeys.push_back(fp); + g_importedT.push_back(addr); + event("import", "m=" + m + " addr=" + addr + " rescan=" + (rescan ? "1" : "0")); + if (rescan) walletScan(m, from); + return c; + } + + // z_importkey "zkey" ( rescan startHeight ) / z_importviewingkey "vkey" ( rescan startHeight zaddr ) + const bool viewing = m == "z_importviewingkey"; + if (params.empty() || params.size() > (viewing ? 4u : 3u)) + return fail(-1, m + (viewing ? " \"vkey\" ( rescan startHeight zaddr )" : " \"zkey\" ( rescan startHeight )")); + if (locked) return fail(-13, kUnlockNeeded); + bool rescan = true; + bool ignoreExisting = true; // the default "whenkeyisnew" + if (params.size() > 1) { + if (!params[1].is_string()) return fail(-1, kNotString); + const std::string r = str(1); + if (r != "whenkeyisnew") { + ignoreExisting = false; + if (r == "yes" || r == "no") rescan = r == "yes"; + else if (!viewing && (r == "true" || r == "false")) rescan = r == "true"; // z_importkey's older API + else return fail(-8, "rescan must be \"yes\", \"no\" or \"whenkeyisnew\""); + } + } + long from = 0; + if (params.size() > 2) { + if (!params[2].is_number_integer()) return fail(-1, kNotInteger); + from = params[2].get(); + } + if (from < 0 || from > tip) return fail(-8, "Block height out of range"); + if (!params[0].is_string()) return fail(-1, kNotString); + const std::string& key = params[0].get_ref(); + std::uint64_t fp = 0; + std::string addr; + if (viewing) { + if (key.rfind("zivks1", 0) != 0) return fail(-5, "Invalid viewing key"); + if (params.size() < 4) return fail(-5, "Missing zaddr for Sapling viewing key."); + if (!params[3].is_string()) return fail(-1, kNotString); + addr = str(3); + if (addr.rfind("zs1", 0) != 0) return fail(-5, "Invalid zaddr"); + fp = fingerprint("v:" + addr); // dragonxd keeps one incoming viewing key per address + } else { + if (key.rfind("secret-extended-key-main1", 0) != 0) return fail(-5, "Invalid spending key"); + fp = fingerprint("z:" + key); + addr = derivedAddress("zs1fakeimport", std::strlen(kFakeZAddr), fp, "0123456789abcdef"); + } + const bool known = std::find(g_importedKeys.begin(), g_importedKeys.end(), fp) != g_importedKeys.end(); + if (known && ignoreExisting) { + event("import", "m=" + m + " known=1 addr=" + addr); + return c; + } + if (!known) { + g_importedKeys.push_back(fp); + if (!viewing) g_importedZ.push_back(addr); // watch-only addresses aren't in z_listaddresses + } + event("import", "m=" + m + (known ? " known=1" : "") + " addr=" + addr + " rescan=" + (rescan ? "1" : "0")); + if (rescan) walletScan(m, from); + return c; +} + +// Keys and passphrases never reach the events file (FAKE_METHODS_ALL). +json redactedParams(const std::string& m, json params) +{ + size_t secrets = 0; + if (m == "importprivkey" || m == "z_importkey" || m == "z_importviewingkey" || m == "walletpassphrase" || + m == "encryptwallet") + secrets = 1; + else if (m == "walletpassphrasechange") + secrets = 2; + for (size_t i = 0; i < secrets && i < params.size(); ++i) params[i] = ""; + return params; +} + Canned cannedReply(const std::string& m, const json& params) { Canned c; @@ -696,6 +971,7 @@ Canned cannedReply(const std::string& m, const json& params) c.result = {{"walletversion", 60000}, {"balance", 0.0}, {"unconfirmed_balance", 0.0}, {"immature_balance", 0.0}, {"txcount", g_cfg.txcount}, {"keypoololdest", now - 86400}, {"keypoolsize", 101}, {"paytxfee", 0.0}}; + if (g_walletEncrypted) c.result["unlocked_until"] = unlockedUntilNow(); } else if (m == "z_gettotalbalance") { c.result = {{"transparent", "0.00"}, {"private", "1234.5678"}, {"total", "1234.5678"}}; } else if (m == "z_getbalance") { @@ -752,7 +1028,19 @@ Canned cannedReply(const std::string& m, const json& params) } else if (m == "getaddressesbyaccount" || m == "listaddresses") { c.result = json::array({kFakeTAddr}); } else if (m == "getrescaninfo") { - c.result = {{"rescanning", false}}; + c.result = {{"rescanning", g_rescanning.load()}}; + if (g_rescanning.load()) { + if (h != 0) { + char progress[16]; + std::snprintf(progress, sizeof(progress), "%.4f", + static_cast(g_rescanHeight.load()) / static_cast(h)); + c.result["rescan_progress"] = progress; // a string, like dragonxd + } + c.result["rescan_start_height"] = g_rescanStart.load(); + c.result["rescan_height"] = g_rescanHeight.load(); + } + } else if (isWalletMethod(m)) { + c.result = nullptr; // stateful: walletReply answers it under cs_main (execOne) } else if (m == "validateaddress") { c.result = {{"isvalid", true}, {"address", param0()}, {"ismine", false}}; } else if (m == "z_validateaddress") { @@ -815,7 +1103,7 @@ json execOne(const json& req, int& status) // FAKE_METHODS_ALL=1: one "call" event per request (method + params), to see the GUI's RPC cadence. static const bool logAll = [] { const char* v = std::getenv("FAKE_METHODS_ALL"); return v && std::string(v) == "1"; }(); if (logAll) { - std::string p = params.dump(); + std::string p = redactedParams(m, params).dump(); if (p.size() > 160) p = p.substr(0, 160) + "..."; event("call", "m=" + m + " p=" + p); } @@ -854,6 +1142,12 @@ json execOne(const json& req, int& status) Canned w = noteWalletReply(m, params); if (w.known) c = w; } + Canned w = walletReply(m, params); // rescan, key imports, FAKE_ENCRYPTED + if (w.known) c = w; + // An imported key's address is listed with the wallet's own from then on. + const auto& imported = m == "z_listaddresses" ? g_importedZ : g_importedT; + if ((m == "z_listaddresses" || m == "getaddressesbyaccount" || m == "listaddresses") && c.result.is_array()) + for (const auto& a : imported) c.result.push_back(a); } if (c.errCode != 0) return fail(c.errCode, c.errMsg); return rpcReply(c.result, nullptr, id); @@ -1422,6 +1716,10 @@ void loadConfig(int argc, char** argv) g_cfg.longestAhead = std::max(0L, envLong("FAKE_LONGEST_AHEAD", g_cfg.longestAhead)); g_cfg.catchupSecs = envDouble("FAKE_CATCHUP_SECS", g_cfg.catchupSecs); g_scanSecs = envDouble("FAKE_SCAN_SECS", 0.0); + g_rescanSecs = std::max(0.0, envDouble("FAKE_RESCAN_SECS", 0.0)); + g_walletEncrypted = envLong("FAKE_ENCRYPTED", 0) == 1; + const char* pass = std::getenv("FAKE_WALLET_PASSPHRASE"); + g_walletPassphrase = (pass && *pass) ? pass : "fake passphrase one"; g_cfg.txcount = envLong("FAKE_TXCOUNT", g_cfg.txcount); g_cfg.height = envLong("FAKE_HEIGHT", g_cfg.height); g_cfg.rebuildTotal = std::max(1L, envLong("FAKE_REBUILD_TOTAL", g_cfg.rebuildTotal));