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 <redacted>.

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 13:22:00 -05:00
co-authored by Claude Opus 5.5
parent 78e38570a8
commit b3a57b07ad
2 changed files with 323 additions and 3 deletions
+300 -2
View File
@@ -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<bool> g_rescanning{false};
std::atomic<long> g_rescanStart{0}, g_rescanHeight{0};
std::vector<std::uint64_t> g_importedKeys; // fingerprints of the imported keys
std::vector<std::string> 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<long long> g_unlockedUntil{0}; // unix seconds; 0 = locked
std::atomic<bool> 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<double>(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<long>(static_cast<double>(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<double>(g_rescanHeight.load()) / static_cast<double>(tip) : 1.0);
}
std::this_thread::sleep_for(50ms);
}
const long long ms = std::chrono::duration_cast<std::chrono::milliseconds>(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<std::string>(); };
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<long long>();
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<long>();
}
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<bool>();
}
long from = 0;
if (rescan && params.size() == 4) {
if (!params[3].is_number_integer()) return fail(-1, kNotInteger);
from = params[3].get<long>();
}
if (from < 0 || from > tip) return fail(-4, "Rescan height is out of range.");
const std::string& key = params[0].get_ref<const std::string&>();
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<long>();
}
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<const std::string&>();
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] = "<redacted>";
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<double>(g_rescanHeight.load()) / static_cast<double>(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));