Files
ObsidianDragon/tests/fake_lite_backend.h
T
DanSandClaude Opus 4.8 16a1ca02e8 fix(lite): lock/unlock parity — shared attempt-lockout, immediate unlock state, no dead PIN, surfaced encrypt failure
Lite-parity audit, FIX C (gating/validation/presentation only — no encrypt/unlock/send backend call changed):
- Lite unlock went straight to the backend with no lockout. New App::unlockLiteWallet() runs the SAME
  escalating, persisted attempt-lockout as the full node (restoreLockoutState/applyUnlockFailure), wired into
  the Settings unlock, the send-time prompt and the lock screen. A wrong-passphrase failure counts; an
  already-unlocked/not-encrypted result does not.
- state_.locked is now cleared immediately on a successful unlock (the ~2s status poll lagged, so the UI
  stayed "locked" after a correct passphrase).
- PIN quick-unlock controls are hidden in lite Settings — lite has no PIN vault, so they were dead.
- A create/restore whose passphrase encryption FAILS now raises a one-shot visible error
  (takeEncryptionFailureNotice -> lite_encrypt_failed_unencrypted) instead of only a log line; the wallet is
  left open but unencrypted. Fixed the flag on BOTH the lifecycle and the direct create/restore encrypt paths.
- A background sync save is skipped while an encrypted wallet is unlocked (the backend save re-locks it),
  resuming once locked.

Compile- and unit-verified (testLiteWalletControllerLockUnlockParity covers the save-deferral + encrypt-failure
notice, run twice for the flaky guard); the lock-screen lockout/PIN-hiding UX itself is runtime/GUI-only and
not exercised here — worth a manual lite GUI pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 15:33:28 -05:00

318 lines
16 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Deterministic in-process fake SDXL backend for lite-wallet tests.
//
// Provides a LiteClientBridgeApi whose function pointers return canned JSON and
// track owned-string allocation/free counts, so tests can drive the lite bridge
// (and, from M1 on, the lite services) without a real litelib_* library, network,
// or the DRAGONX_ENABLE_LITE_BACKEND build flag. Build a bridge with:
//
// auto bridge = dragonx::wallet::LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
//
// Invariant for leak/double-free checks: after a test, g_liteFakeAlloc == g_liteFakeFreed.
#pragma once
#include "wallet/lite_client_bridge.h"
#include <atomic>
#include <chrono>
#include <cstdlib>
#include <cstring>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
namespace dragonx {
namespace test {
// Owned-string accounting (atomic: a detached sync thread may touch these concurrently).
inline std::atomic<long> g_liteFakeAlloc{0}; // owned strings handed to the bridge
inline std::atomic<long> g_liteFakeFreed{0}; // owned strings released via freeString
inline bool g_liteFakeWalletExists = true;
inline bool g_liteFakeServerOnline = true;
inline bool g_liteFakeShutdownCalled = false;
inline std::atomic<bool> g_liteFakeSyncBlock{false}; // when true, the "sync" command blocks
inline std::atomic<int> g_liteFakeSyncFailures{0}; // next N "sync" commands fail with the real plain-text shape
inline std::atomic<bool> g_liteFakeSyncTerminal{false}; // "sync" returns the terminal reorg error
inline std::atomic<int> g_liteFakeRescanFailures{0}; // next N "rescan" commands fail
inline std::atomic<long> g_liteFakeSyncCalls{0};
inline std::atomic<bool> g_liteFakeBadBalance{false}; // when true, "balance" returns invalid JSON
inline std::atomic<bool> g_liteFakeSendFails{false}; // when true, "send"/"shield" return {"error":..}
inline std::atomic<long> g_liteFakeSaveCount{0}; // counts "save" commands (persistence checks)
inline std::atomic<unsigned long long> g_liteFakeHeight{1000}; // settable last-scanned height (C23 gate test)
inline std::atomic<bool> g_liteFakeEncrypted{false}; // wallet-encryption state machine (encrypt/...)
inline std::atomic<bool> g_liteFakeLocked{false}; // spending-keys-locked state
inline std::atomic<bool> g_liteFakeBlockNew{false}; // "new" blocks (models do_save waiting on sync_lock)
inline std::atomic<bool> g_liteFakeBlockSave{false}; // "save" blocks
inline std::atomic<bool> g_liteFakeBlockBalance{false}; // "balance" blocks (a refresh stuck on a dead server)
inline std::atomic<bool> g_liteFakeBalanceError{false}; // "balance" returns the backend's plain-text sync error
inline std::atomic<long> g_liteFakeBalanceEntered{0}; // calls that reached "balance"
inline std::atomic<bool> g_liteFakeBlockEncrypt{false}; // "encrypt" blocks (before changing state)
inline std::atomic<bool> g_liteFakeEncryptFails{false}; // "encrypt" returns an error and changes no state
inline std::atomic<bool> g_liteFakeBlockImport{false}; // "import"/"timport" block
inline std::atomic<bool> g_liteFakeImportFails{false}; // "import"/"timport" reject the key (real do_import_key shape)
inline std::atomic<bool> g_liteFakeImportEncrypted{false}; // "import"/"timport" refuse: the wallet is encrypted
inline std::atomic<bool> g_liteFakeLogEnabled{false};
inline std::mutex g_liteFakeLogMutex;
inline std::vector<std::string> g_liteFakeCommandLog; // execute() command names, in order, while enabled
inline void liteFakeWaitWhile(const std::atomic<bool>& flag)
{
while (flag.load()) std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
inline std::vector<std::string> liteFakeTakeLog()
{
std::lock_guard<std::mutex> lk(g_liteFakeLogMutex);
std::vector<std::string> out;
out.swap(g_liteFakeCommandLog);
return out;
}
inline long liteFakeCount(const std::vector<std::string>& log, const char* command)
{
long n = 0;
for (const auto& c : log) if (c == command) ++n;
return n;
}
// initialize_existing fails for any server URL containing this substring (open-failover tests).
inline std::string g_liteFakeDeadServerSubstr;
// initialize_existing returns a warming-up (-28) error for URLs containing this substring.
inline std::string g_liteFakeWarmupServerSubstr;
// When non-empty, initialize_existing returns "OK: <note>" (a wallet recovered from its backup, s2-D14).
inline std::string g_liteFakeOpenRecoveredNote;
// When non-empty, "seed" returns this phrase instead of the default one.
inline std::string g_liteFakeSeedPhrase;
inline void resetLiteFakeCounters()
{
g_liteFakeAlloc = 0;
g_liteFakeFreed = 0;
g_liteFakeHeight = 1000;
g_liteFakeShutdownCalled = false;
g_liteFakeDeadServerSubstr.clear();
g_liteFakeWarmupServerSubstr.clear();
g_liteFakeOpenRecoveredNote.clear();
g_liteFakeSeedPhrase.clear();
g_liteFakeBlockNew = false;
g_liteFakeBlockSave = false;
g_liteFakeBlockBalance = false;
g_liteFakeBalanceError = false;
g_liteFakeBlockEncrypt = false;
g_liteFakeBlockImport = false;
g_liteFakeImportFails = false;
g_liteFakeImportEncrypted = false;
g_liteFakeSyncFailures = 0;
g_liteFakeSyncTerminal = false;
g_liteFakeRescanFailures = 0;
g_liteFakeSyncCalls = 0;
g_liteFakeLogEnabled = false;
(void)liteFakeTakeLog();
}
inline char* liteFakeDup(const char* s)
{
char* p = static_cast<char*>(std::malloc(std::strlen(s) + 1));
std::strcpy(p, s);
++g_liteFakeAlloc;
return p;
}
inline bool liteFakeWalletExists(const char*) { return g_liteFakeWalletExists; }
// Match the real litelib_* return shapes so tests exercise the production walletReady path:
// create/restore return a seed object ({"seed":..,"birthday":..}); open returns the bare
// string "OK" (NOT JSON) — see litelib_initialize_existing.
inline char* liteFakeInitNew(bool, const char* server)
{
// Honor the dead/warmup substrings so create-with-failover can be exercised (mirrors how the
// real initialize_new contacts the server first and fails before writing a wallet).
if (server) {
const std::string s(server);
if (!g_liteFakeWarmupServerSubstr.empty() &&
s.find(g_liteFakeWarmupServerSubstr) != std::string::npos)
return liteFakeDup("Error: grpc-message: \"error requesting block: -28: "
"Activating best chain...\"");
if (!g_liteFakeDeadServerSubstr.empty() &&
s.find(g_liteFakeDeadServerSubstr) != std::string::npos)
return liteFakeDup("Error: could not connect to server");
}
return liteFakeDup("{\"seed\":\"fake seed phrase words\",\"birthday\":0}");
}
inline char* liteFakeInitFromPhrase(bool, const char*, const char*,
unsigned long long, unsigned long long, bool)
{
return liteFakeDup("{\"seed\":\"fake seed phrase words\",\"birthday\":0}");
}
// initialize_existing fails (server unreachable) for any server URL containing
// g_liteFakeDeadServerSubstr, so tests can exercise the controller's open-with-failover.
inline char* liteFakeInitExisting(bool, const char* server)
{
if (server) {
const std::string s(server);
if (!g_liteFakeWarmupServerSubstr.empty() &&
s.find(g_liteFakeWarmupServerSubstr) != std::string::npos) {
// Server is up but warming up — mirrors the real -28 / "Activating best chain".
return liteFakeDup("Error: grpc-message: \"error requesting block: -28: "
"Activating best chain...\"");
}
if (!g_liteFakeDeadServerSubstr.empty() &&
s.find(g_liteFakeDeadServerSubstr) != std::string::npos) {
return liteFakeDup("Error: could not connect to server"); // bridge maps to ok=false
}
}
if (!g_liteFakeOpenRecoveredNote.empty()) return liteFakeDup(("OK: " + g_liteFakeOpenRecoveredNote).c_str());
return liteFakeDup("OK");
}
inline char* liteFakeExecute(const char* command, const char* args)
{
if (command && g_liteFakeLogEnabled.load()) {
std::lock_guard<std::mutex> lk(g_liteFakeLogMutex);
g_liteFakeCommandLog.emplace_back(command);
}
// new-address generation returns a JSON array with the new address (type from args: zs/R).
if (command && std::strcmp(command, "new") == 0) {
liteFakeWaitWhile(g_liteFakeBlockNew);
return liteFakeDup(args && std::strcmp(args, "R") == 0 ? "[\"R1fakenew\"]" : "[\"zs1fakenew\"]");
}
if (command && std::strcmp(command, "save") == 0) liteFakeWaitWhile(g_liteFakeBlockSave); // counted below
if (command && std::strcmp(command, "balance") == 0) {
++g_liteFakeBalanceEntered;
liteFakeWaitWhile(g_liteFakeBlockBalance);
// Real shape of a failed do_sync: bare text, NO "Error:" prefix -> bridge ok=true, non-JSON value.
if (g_liteFakeBalanceError.load()) return liteFakeDup("Error getting latest block: simulated connect failure");
}
// A command named "boom" yields an "Error:"-prefixed response (the bridge's
// looksLikeError contract maps that to ok=false).
if (command && std::strcmp(command, "boom") == 0) {
return liteFakeDup("Error: simulated lite backend failure");
}
// Command-appropriate canned responses matching the litelib JSON shapes (see
// tests/fixtures/lite/result_parsers.json), so the gateway/sync refresh path parses.
if (command) {
const char* c = command;
if (std::strcmp(c, "sync") == 0) {
// Simulate the real backend's blocking full sync when requested, so tests can
// verify shutdown doesn't hang on an in-flight sync.
while (g_liteFakeSyncBlock.load()) std::this_thread::sleep_for(std::chrono::milliseconds(5));
++g_liteFakeSyncCalls;
if (g_liteFakeSyncTerminal.load()) return liteFakeDup("Reorg has exceeded 100 blocks. Aborting.");
if (g_liteFakeSyncFailures.load() > 0) { --g_liteFakeSyncFailures; return liteFakeDup("Error getting latest block: simulated connect failure"); }
return liteFakeDup("{\"result\":\"success\"}");
}
if (std::strcmp(c, "rescan") == 0) {
if (g_liteFakeRescanFailures.load() > 0) { --g_liteFakeRescanFailures; return liteFakeDup("Error fetching blocks: simulated"); }
return liteFakeDup("{\"result\":\"success\"}");
}
if (std::strcmp(c, "syncstatus") == 0) // real backend shape: "syncing" is a string
return liteFakeDup("{\"syncing\":\"true\",\"synced_blocks\":1000,\"total_blocks\":1000}");
if (std::strcmp(c, "balance") == 0 && g_liteFakeBadBalance.load())
return liteFakeDup("not-json"); // simulate a shape/parse failure for one command
if (std::strcmp(c, "balance") == 0)
return liteFakeDup("{\"tbalance\":100000000,\"zbalance\":200000000,\"unconfirmed\":50000000,"
"\"verified_zbalance\":180000000,\"spendable_zbalance\":170000000}");
if (std::strcmp(c, "addresses") == 0)
return liteFakeDup("{\"z_addresses\":[\"zs1fakeaddr\"],\"t_addresses\":[\"R1fakeaddr\"]}");
if (std::strcmp(c, "notes") == 0)
return liteFakeDup("{\"unspent_notes\":[],\"utxos\":[],\"pending_notes\":[],\"pending_utxos\":[]}");
if (std::strcmp(c, "list") == 0)
return liteFakeDup("[{\"txid\":\"faketx\",\"datetime\":1700000000,\"block_height\":990,"
"\"unconfirmed\":false,\"address\":\"zs1fakeaddr\",\"amount\":150000000,\"memo\":\"\"}]");
if (std::strcmp(c, "height") == 0)
return liteFakeDup(("{\"height\":" + std::to_string(g_liteFakeHeight.load()) + "}").c_str());
if (std::strcmp(c, "info") == 0)
return liteFakeDup("{\"chain_name\":\"main\",\"version\":\"sdxl-fake\",\"latest_block_height\":1000}");
// M4 spend/backup commands. send/shield report failure via {"error":..} (NOT an
// "Error:" prefix), matching the real backend's object!{ "error" => e } shape.
if (std::strcmp(c, "send") == 0) {
if (g_liteFakeSendFails.load()) return liteFakeDup("{\n \"error\": \"insufficient funds\"\n}");
return liteFakeDup("{\n \"txid\": \"faketxid123\"\n}");
}
if (std::strcmp(c, "shield") == 0) {
if (g_liteFakeSendFails.load()) return liteFakeDup("{\n \"error\": \"nothing to shield\"\n}");
return liteFakeDup("{\n \"txid\": \"fakeshieldtxid\"\n}");
}
if (std::strcmp(c, "export") == 0)
return liteFakeDup("[{\"address\":\"zs1fakeaddr\",\"private_key\":\"SECRET-ZKEY\","
"\"viewing_key\":\"zxviewsFAKE\"}]");
if (std::strcmp(c, "seed") == 0) {
if (!g_liteFakeSeedPhrase.empty())
return liteFakeDup(("{\"seed\":\"" + g_liteFakeSeedPhrase + "\",\"birthday\":0}").c_str());
return liteFakeDup("{\"seed\":\"fake seed phrase words\",\"birthday\":0}");
}
// import (shielded key) / timport (transparent WIF): do_import_* pretty-print JSON.
if (std::strcmp(c, "import") == 0 || std::strcmp(c, "timport") == 0) {
liteFakeWaitWhile(g_liteFakeBlockImport);
// The real refusal (lightclient do_import_tk/do_import_sk, prefixed by the console command).
if (g_liteFakeImportEncrypted.load())
return liteFakeDup("Error: Can't import a private key while the wallet is encrypted - remove "
"encryption, import, then encrypt again");
// The real do_import_key error REPEATS the key: "Error: '<key>' was not recognized as ...".
if (g_liteFakeImportFails.load())
return liteFakeDup((std::string("Error: '") + (args ? args : "") +
"' was not recognized as either a spending key or a viewing key").c_str());
return liteFakeDup("{\"result\":\"success\",\"address\":\"zs1imported\"}");
}
if (std::strcmp(c, "save") == 0) {
++g_liteFakeSaveCount;
return liteFakeDup("{\"result\":\"success\"}");
}
// Encryption state machine. encrypt -> encrypted AND locked (matches the real backend,
// which removes keys from memory right after encrypting — verified via lite_smoke --encrypt);
// unlock/lock toggle locked; decrypt clears it; encryptionstatus reports {encrypted,locked}.
if (std::strcmp(c, "encrypt") == 0) {
liteFakeWaitWhile(g_liteFakeBlockEncrypt);
if (g_liteFakeEncryptFails.load())
return liteFakeDup("{\"result\":\"error\",\"error\":\"encrypt failed\"}");
g_liteFakeEncrypted = true; g_liteFakeLocked = true;
return liteFakeDup("{\"result\":\"success\"}");
}
if (std::strcmp(c, "unlock") == 0) {
g_liteFakeLocked = false;
return liteFakeDup("{\"result\":\"success\"}");
}
if (std::strcmp(c, "lock") == 0) {
g_liteFakeLocked = true;
return liteFakeDup("{\"result\":\"success\"}");
}
if (std::strcmp(c, "decrypt") == 0) {
g_liteFakeEncrypted = false; g_liteFakeLocked = false;
return liteFakeDup("{\"result\":\"success\"}");
}
if (std::strcmp(c, "encryptionstatus") == 0) {
return liteFakeDup(g_liteFakeEncrypted.load()
? (g_liteFakeLocked.load() ? "{\"encrypted\":true,\"locked\":true}"
: "{\"encrypted\":true,\"locked\":false}")
: "{\"encrypted\":false,\"locked\":false}");
}
}
// Default for any other/unknown command.
return liteFakeDup("{\"version\":\"sdxl-fake\"}");
}
inline void liteFakeFree(char* v)
{
if (v) {
++g_liteFakeFreed;
std::free(v);
}
}
inline bool liteFakeServerOnline(const char*) { return g_liteFakeServerOnline; }
inline void liteFakeShutdown() { g_liteFakeShutdownCalled = true; }
inline dragonx::wallet::LiteClientBridgeApi makeFakeLiteApi()
{
dragonx::wallet::LiteClientBridgeApi api;
api.walletExists = &liteFakeWalletExists;
api.initializeNew = &liteFakeInitNew;
api.initializeNewFromPhrase = &liteFakeInitFromPhrase;
api.initializeExisting = &liteFakeInitExisting;
api.execute = &liteFakeExecute;
api.freeString = &liteFakeFree;
api.checkServerOnline = &liteFakeServerOnline;
api.shutdown = &liteFakeShutdown;
return api;
}
} // namespace test
} // namespace dragonx