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
+23 -1
View File
@@ -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 `<datadir>/fake_dragonxd.events`, one
@@ -54,6 +70,10 @@ Everything that matters for a test is appended to `<datadir>/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=<method> p=<params>` 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=<method> p=<params>` event per request (the GUI's RPC cadence; the `methods` lines keep only the top 10 counts). Keys and passphrases show as `<redacted>` |
| `FAKE_HEIGHT`, `FAKE_REBUILD_TOTAL`, `FAKE_NOTES`, `FAKE_VERSION` | 3327145, 62583, 13632, 1030050 | cosmetic numbers |
## Test cases
+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));