Compare commits
8 Commits
lite-v1.1.
...
lite-v1.1.
| Author | SHA1 | Date | |
|---|---|---|---|
| 46189da0da | |||
| bc6c1de7d6 | |||
| ec88dac459 | |||
| d058e5fc2a | |||
| 86b9d2e00d | |||
| 0056c46929 | |||
| 1151141271 | |||
| fae8b20fb7 |
@@ -15,7 +15,7 @@ if(APPLE)
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
project(ObsidianDragon
|
project(ObsidianDragon
|
||||||
VERSION 2.0.1
|
VERSION 2.0.2
|
||||||
LANGUAGES C CXX
|
LANGUAGES C CXX
|
||||||
DESCRIPTION "DragonX Cryptocurrency Wallet"
|
DESCRIPTION "DragonX Cryptocurrency Wallet"
|
||||||
)
|
)
|
||||||
@@ -26,7 +26,7 @@ set(DRAGONX_VERSION_SUFFIX "")
|
|||||||
# ObsidianDragonLite is versioned INDEPENDENTLY of the full-node app above. The active variant's
|
# ObsidianDragonLite is versioned INDEPENDENTLY of the full-node app above. The active variant's
|
||||||
# version flows to the generated header, the Windows .rc/manifest, and build.sh's release names via
|
# version flows to the generated header, the Windows .rc/manifest, and build.sh's release names via
|
||||||
# DRAGONX_APP_VERSION* (resolved in the lite/full block below).
|
# DRAGONX_APP_VERSION* (resolved in the lite/full block below).
|
||||||
set(DRAGONX_LITE_VERSION "1.1.0")
|
set(DRAGONX_LITE_VERSION "1.1.1")
|
||||||
set(DRAGONX_LITE_VERSION_SUFFIX "")
|
set(DRAGONX_LITE_VERSION_SUFFIX "")
|
||||||
|
|
||||||
# C++17 standard
|
# C++17 standard
|
||||||
|
|||||||
43
docs/release-notes/lite-v1.1.1.md
Normal file
43
docs/release-notes/lite-v1.1.1.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
**DragonX (DRGX) Lite Desktop Wallet — v1.1.1**
|
||||||
|
|
||||||
|
ObsidianDragonLite is the lightweight, node-free companion to ObsidianDragon — it connects to a DragonX lite-wallet server for near-instant startup, with the same native ImGui interface and shielded-first workflow. **v1.1.1 is a maintenance release** centered on an important wallet-encryption fix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Security fix — please read
|
||||||
|
|
||||||
|
**Creating or restoring a Lite wallet with a passphrase now actually encrypts it.**
|
||||||
|
|
||||||
|
In prior versions, the create / restore / open flow used by the Settings UI **silently discarded the passphrase you entered**:
|
||||||
|
|
||||||
|
- A **newly created or restored** wallet was saved to disk **unencrypted** — the seed and spending keys were stored in the clear even though you set a passphrase.
|
||||||
|
- **Opening** an already-encrypted wallet did not unlock it with the supplied passphrase (it opened locked and re-prompted).
|
||||||
|
|
||||||
|
v1.1.1 fixes this: create/restore now **encrypt** the wallet with your passphrase (the backend encrypts and locks it), and open now **unlocks** it.
|
||||||
|
|
||||||
|
**What you should do:** if you created or restored a Lite wallet **with a passphrase** on an earlier version, treat that wallet's on-disk data as unencrypted. Open it in v1.1.1 and re-apply encryption from **Settings → encrypt** (and, if you're concerned the plaintext wallet file may have been exposed, move the funds to a freshly created encrypted wallet). Wallets you never set a passphrase on are unaffected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Also fixed
|
||||||
|
|
||||||
|
- **Chinese, Japanese, and Korean FAQ text renders correctly.** Several Help/FAQ titles showed `??` instead of characters (e.g. 什么, 门) after the previous translation back-fill; the bundled CJK font subset now includes those glyphs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Downloads
|
||||||
|
|
||||||
|
macOS builds are x86-64 (Intel; run on Apple Silicon via Rosetta 2).
|
||||||
|
|
||||||
|
| File | SHA-256 |
|
||||||
|
|------|---------|
|
||||||
|
| ObsidianDragonLite-1.1.1.AppImage | `323ff5f8658a5550aa12678ce4be37e32a2bfd3f3b0543074cc86a4267895dcf` |
|
||||||
|
| ObsidianDragonLite-1.1.1-Linux-x64.zip | `e8604e479aed7e020946df45bf0d9373401354907e10565011694efa9978e631` |
|
||||||
|
| ObsidianDragonLite-1.1.1.exe | `4cdcf291fe43c4fed04258aa55bb4e9b591f52780f9db5fb8f8bef522c43a351` |
|
||||||
|
| ObsidianDragonLite-1.1.1-Windows-x64.zip | `c387bfcda508f32195c41fabec9f23d8b094d693a5dba41fbf8ec4abab6f2f6e` |
|
||||||
|
| ObsidianDragonLite-1.1.1-macOS-x86_64.dmg | `bb0c491cb8f90ae55d162ec7c04c8f30988fa2f79c7911471220cddab44f99a5` |
|
||||||
|
| ObsidianDragonLite-1.1.1-macOS-x86_64.app.zip | `a2a75fe40bf1f4db3abed8206dcc40d9fc74d8db2df7704afc9c834106b1c247` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
For the full feature set (node-free operation, wallet lifecycle, shielded + transparent send/shield/receive, HushChat, server browser with failover, 8-language UI), see the **lite-v1.0.0** release notes — v1.1.1 changes only the items listed above.
|
||||||
41
docs/release-notes/v2.0.2.md
Normal file
41
docs/release-notes/v2.0.2.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
**DragonX (DRGX) Full-Node Desktop Wallet — v2.0.2**
|
||||||
|
|
||||||
|
ObsidianDragon is a native ImGui-based full-node wallet for the DragonX network, with an embedded `dragonxd` daemon and the integrated DRG-XMRig CPU miner. **v2.0.2 is a maintenance release** — a focused set of transaction-history, sync-safety, and correctness fixes on top of v2.0.x. No configuration changes are required; just replace the binary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's Fixed in v2.0.2
|
||||||
|
|
||||||
|
### Transaction history
|
||||||
|
|
||||||
|
- **Outgoing shielded sends now appear in History.** Sends *from* a shielded (z-)address — both **z→z** (shielded-to-shielded) and **z→t** (deshield to a transparent address) — were missing from the History tab, because the daemon's `listtransactions` doesn't report them and `z_viewtransaction` doesn't expose the transparent leg. The wallet now enumerates them via `z_listsentbyaddress`, so **your History matches your balance.** (Your funds were never affected — only the display.)
|
||||||
|
- **More accurate send rows** — a single transaction with two equal-value outputs to different recipients is no longer collapsed into one row, and a transparent self-send / change output is no longer double-counted as both sent and received.
|
||||||
|
|
||||||
|
### Sync & fund safety
|
||||||
|
|
||||||
|
- **A node that loses all its peers is no longer reported as "synced."** Previously, a wallet whose node dropped to **0 peers** could keep showing a stale balance as if it were current — and allow a Send against it. The wallet now treats a node with no peer-derived chain tip as *not synced*, and a new **"Connected, but no network peers — the node can't sync"** banner explains the stall instead of silently looking healthy.
|
||||||
|
- **Migrate-to-seed hardening.** If a migrate-to-seed was interrupted (app or daemon restart) *after* the sweep transaction had broadcast but *before* it confirmed, the wallet could mistake the temporarily-zero balance for an empty wallet and adopt the new seed wallet — swapping `wallet.dat` — before the sweep was mined. It now records that a sweep is in flight and **will not swap your wallet until the sweep confirms**, so funds can't be stranded in the timestamped backup.
|
||||||
|
- **Fixed a rare crash** caused by a data race on the daemon-status text (the daemon-monitor thread writing it while the UI thread read it).
|
||||||
|
|
||||||
|
### Display / internationalization
|
||||||
|
|
||||||
|
- **Chinese, Japanese, and Korean FAQ text renders correctly.** Several Help/FAQ titles showed `??` instead of characters (e.g. 什么, 门) after the previous translation back-fill; the bundled CJK font subset now includes those glyphs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Downloads
|
||||||
|
|
||||||
|
macOS builds are x86-64 (Intel; run on Apple Silicon via Rosetta 2).
|
||||||
|
|
||||||
|
| File | SHA-256 |
|
||||||
|
|------|---------|
|
||||||
|
| ObsidianDragon-2.0.2.AppImage | `9bf7328965c6348113ba7a30b0284f15787c635af17efae3dfa8a87e56e10c9b` |
|
||||||
|
| ObsidianDragon-2.0.2-Linux-x64.zip | `1371880c4187a94d4cb56d535471142c5f1a8053ef73fffd99d59e7b78c75c42` |
|
||||||
|
| ObsidianDragon-2.0.2.exe | `673347d2696fb6f5bd09dc25e415d5ae8e532abde75b8d48c3cd26545461de50` |
|
||||||
|
| ObsidianDragon-2.0.2-Windows-x64.zip | `f5ea7c45e66c6c1b90a8127f96dee04235691acb913ddc86327f2060798cd28c` |
|
||||||
|
| ObsidianDragon-2.0.2-macOS-x86_64.dmg | `7038580807a8e93e5443bdc04f2aede3ae1c30e5bc9f8776a207edb2e87771d1` |
|
||||||
|
| ObsidianDragon-2.0.2-macOS-x86_64.app.zip | `78159512ca684b95528231fd46f2d07b5bcc6f2da4d815fc1ecb2ff52127f41c` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
For the full feature set (HushChat messaging, seed-phrase wallets & migrate-to-seed, in-app node/miner updaters, built-in mining, 8-language UI), see the **v2.0.0** release notes — v2.0.2 changes only the fixes listed above.
|
||||||
Binary file not shown.
76
src/app.cpp
76
src/app.cpp
@@ -11,6 +11,7 @@
|
|||||||
#include "rpc/rpc_worker.h"
|
#include "rpc/rpc_worker.h"
|
||||||
#include "rpc/connection.h"
|
#include "rpc/connection.h"
|
||||||
#include "config/settings.h"
|
#include "config/settings.h"
|
||||||
|
#include "data/seed_migration_resume.h"
|
||||||
#include "wallet/lite_wallet_controller.h"
|
#include "wallet/lite_wallet_controller.h"
|
||||||
#include "wallet/lite_wallet_server_selection_adapter.h"
|
#include "wallet/lite_wallet_server_selection_adapter.h"
|
||||||
#include "wallet/lite_rollout_policy.h"
|
#include "wallet/lite_rollout_policy.h"
|
||||||
@@ -2299,6 +2300,8 @@ void App::renderNodeStatusBanner()
|
|||||||
in.connection_status = connection_status_;
|
in.connection_status = connection_status_;
|
||||||
in.daemon_last_error = daemon_controller_ ? daemon_controller_->lastError() : std::string();
|
in.daemon_last_error = daemon_controller_ ? daemon_controller_->lastError() : std::string();
|
||||||
in.lite_open_error = lite_open_error_;
|
in.lite_open_error = lite_open_error_;
|
||||||
|
in.tip_known = state_.sync.tip_known;
|
||||||
|
in.blocks = state_.sync.blocks;
|
||||||
|
|
||||||
const ui::NodeBannerState banner = ui::evaluateNodeStatusBanner(in);
|
const ui::NodeBannerState banner = ui::evaluateNodeStatusBanner(in);
|
||||||
if (!banner.show) return;
|
if (!banner.show) return;
|
||||||
@@ -2331,6 +2334,8 @@ void App::renderNodeStatusBanner()
|
|||||||
title = TR("node_banner_crashed_title"); icon = ICON_MD_ERROR; break;
|
title = TR("node_banner_crashed_title"); icon = ICON_MD_ERROR; break;
|
||||||
case ui::NodeBannerReason::LiteOpenFailed:
|
case ui::NodeBannerReason::LiteOpenFailed:
|
||||||
title = TR("node_banner_lite_open_failed"); icon = ICON_MD_ERROR; break;
|
title = TR("node_banner_lite_open_failed"); icon = ICON_MD_ERROR; break;
|
||||||
|
case ui::NodeBannerReason::NoPeers:
|
||||||
|
title = TR("node_banner_no_peers_title"); icon = ICON_MD_CLOUD_OFF; break;
|
||||||
case ui::NodeBannerReason::FullNodeOffline:
|
case ui::NodeBannerReason::FullNodeOffline:
|
||||||
default:
|
default:
|
||||||
title = TR("node_banner_offline_title"); icon = ICON_MD_CLOUD_OFF; break;
|
title = TR("node_banner_offline_title"); icon = ICON_MD_CLOUD_OFF; break;
|
||||||
@@ -2817,7 +2822,8 @@ void App::renderStatusBar()
|
|||||||
ImGui::TextDisabled("%s", statusShown.c_str());
|
ImGui::TextDisabled("%s", statusShown.c_str());
|
||||||
}
|
}
|
||||||
occupiedX = statusX;
|
occupiedX = statusX;
|
||||||
} else if (!daemon_status_.empty() && daemon_status_.find("Error") != std::string::npos) {
|
} else if (const std::string ds = getDaemonStatus();
|
||||||
|
!ds.empty() && ds.find("Error") != std::string::npos) {
|
||||||
const char* errText = TR("sb_daemon_not_found");
|
const char* errText = TR("sb_daemon_not_found");
|
||||||
float statusW = ImGui::CalcTextSize(errText).x;
|
float statusW = ImGui::CalcTextSize(errText).x;
|
||||||
float statusX = versionX - statusW - gap;
|
float statusX = versionX - statusW - gap;
|
||||||
@@ -4314,8 +4320,33 @@ void App::renderSeedMigrationDialog()
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (seed_migration_balance_ <= DRAGONX_DEFAULT_FEE) {
|
if (seed_migration_balance_ <= DRAGONX_DEFAULT_FEE) {
|
||||||
// Nothing to sweep — adopt the fresh seed wallet directly. Its wallet.dat replaces the
|
const bool sweepSubmitted = settings_ && settings_->getSeedMigrationSweepSubmitted();
|
||||||
// current (empty) one; adopt still moves the legacy wallet aside to a timestamped backup.
|
if (!dragonx::sweepGateMayAdoptEmptyWallet(sweepSubmitted)) {
|
||||||
|
// A sweep was already broadcast but its txid was lost (its opid didn't survive a
|
||||||
|
// daemon/app restart). A ~0 balance is AMBIGUOUS — the sweep may still be unconfirmed
|
||||||
|
// in the mempool and could revert on a drop/reorg — so we must NOT adopt (swap
|
||||||
|
// wallet.dat) here. Try to locate the sweep tx to resume the confirmation gate (which
|
||||||
|
// requires >=1 conf); until then, wait/refresh. If the sweep was dropped the balance
|
||||||
|
// simply returns and the normal Sweep UI reappears — the user is never trapped.
|
||||||
|
ImGui::TextWrapped("%s", TR("mig_sweep_inflight"));
|
||||||
|
ImGui::Spacing();
|
||||||
|
ImGui::TextColored(kMedium, "%s%s", TR("mig_checking_sweep"), ui::material::LoadingDots());
|
||||||
|
ImGui::Spacing();
|
||||||
|
if (ui::material::TactileButton(TR("mig_check_sweep"), ImVec2(190 * dp, 0)))
|
||||||
|
locateSweepTxid();
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0))) {
|
||||||
|
refreshSeedMigrationBalance();
|
||||||
|
locateSweepTxid();
|
||||||
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0)))
|
||||||
|
close();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Nothing to sweep and no sweep was ever submitted (a genuinely empty wallet) — adopt the
|
||||||
|
// fresh seed wallet directly. Its wallet.dat replaces the current (empty) one; adopt still
|
||||||
|
// moves the legacy wallet aside to a timestamped backup.
|
||||||
ImGui::TextWrapped("%s", TR("mig_no_funds"));
|
ImGui::TextWrapped("%s", TR("mig_no_funds"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextWrapped("%s%s", TR("mig_to"), seed_migration_dest_.c_str());
|
ImGui::TextWrapped("%s%s", TR("mig_to"), seed_migration_dest_.c_str());
|
||||||
@@ -5166,6 +5197,18 @@ void App::setCurrentTab(int tab) {
|
|||||||
setCurrentPage(kTabMap[tab]);
|
setCurrentPage(kTabMap[tab]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string App::getDaemonStatus() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(daemon_status_mutex_);
|
||||||
|
return daemon_status_; // a copy — safe to use after the lock is released
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::setDaemonStatus(std::string s)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(daemon_status_mutex_);
|
||||||
|
daemon_status_ = std::move(s);
|
||||||
|
}
|
||||||
|
|
||||||
bool App::startEmbeddedDaemon()
|
bool App::startEmbeddedDaemon()
|
||||||
{
|
{
|
||||||
if (!supportsEmbeddedDaemon()) {
|
if (!supportsEmbeddedDaemon()) {
|
||||||
@@ -5185,16 +5228,16 @@ bool App::startEmbeddedDaemon()
|
|||||||
// Try to extract embedded resources if available
|
// Try to extract embedded resources if available
|
||||||
if (resources::hasEmbeddedResources()) {
|
if (resources::hasEmbeddedResources()) {
|
||||||
DEBUG_LOGF("Extracting embedded Sapling params...\n");
|
DEBUG_LOGF("Extracting embedded Sapling params...\n");
|
||||||
daemon_status_ = TR("sb_extracting_sapling");
|
setDaemonStatus(TR("sb_extracting_sapling"));
|
||||||
if (!resources::extractEmbeddedResources()) {
|
if (!resources::extractEmbeddedResources()) {
|
||||||
daemon_status_ = TR("sb_daemon_extract_failed");
|
setDaemonStatus(TR("sb_daemon_extract_failed"));
|
||||||
DEBUG_LOGF("[ERROR] extractEmbeddedResources() failed — disk full or permission denied?\n");
|
DEBUG_LOGF("[ERROR] extractEmbeddedResources() failed — disk full or permission denied?\n");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check again after extraction
|
// Check again after extraction
|
||||||
if (!rpc::Connection::verifySaplingParams()) {
|
if (!rpc::Connection::verifySaplingParams()) {
|
||||||
daemon_status_ = TR("sb_sapling_failed");
|
setDaemonStatus(TR("sb_sapling_failed"));
|
||||||
DEBUG_LOGF("Sapling params still not found after extraction!\n");
|
DEBUG_LOGF("Sapling params still not found after extraction!\n");
|
||||||
DEBUG_LOGF("Expected location: %s\n", rpc::Connection::getSaplingParamsDir().c_str());
|
DEBUG_LOGF("Expected location: %s\n", rpc::Connection::getSaplingParamsDir().c_str());
|
||||||
return false;
|
return false;
|
||||||
@@ -5211,7 +5254,7 @@ bool App::startEmbeddedDaemon()
|
|||||||
if (!exe_dir.empty()) {
|
if (!exe_dir.empty()) {
|
||||||
std::string dirErr;
|
std::string dirErr;
|
||||||
if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) {
|
if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) {
|
||||||
daemon_status_ = dirErr;
|
setDaemonStatus(dirErr);
|
||||||
DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str());
|
DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -5245,7 +5288,7 @@ bool App::startEmbeddedDaemon()
|
|||||||
if (copied && rpc::Connection::verifySaplingParams()) {
|
if (copied && rpc::Connection::verifySaplingParams()) {
|
||||||
DEBUG_LOGF("Sapling params copied from exe directory successfully\n");
|
DEBUG_LOGF("Sapling params copied from exe directory successfully\n");
|
||||||
} else {
|
} else {
|
||||||
daemon_status_ = TR("sb_sapling_not_found");
|
setDaemonStatus(TR("sb_sapling_not_found"));
|
||||||
DEBUG_LOGF("Sapling params not found and no embedded resources available!\n");
|
DEBUG_LOGF("Sapling params not found and no embedded resources available!\n");
|
||||||
DEBUG_LOGF("Expected location: %s\n", rpc::Connection::getSaplingParamsDir().c_str());
|
DEBUG_LOGF("Expected location: %s\n", rpc::Connection::getSaplingParamsDir().c_str());
|
||||||
return false;
|
return false;
|
||||||
@@ -5262,7 +5305,7 @@ bool App::startEmbeddedDaemon()
|
|||||||
if (!exe_dir.empty()) {
|
if (!exe_dir.empty()) {
|
||||||
std::string dirErr;
|
std::string dirErr;
|
||||||
if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) {
|
if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) {
|
||||||
daemon_status_ = dirErr;
|
setDaemonStatus(dirErr);
|
||||||
DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str());
|
DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -5298,7 +5341,7 @@ bool App::startEmbeddedDaemon()
|
|||||||
if (copyFailed) {
|
if (copyFailed) {
|
||||||
char buf[512];
|
char buf[512];
|
||||||
snprintf(buf, sizeof(buf), TR("sb_daemon_files_failed"), daemon_dir.c_str());
|
snprintf(buf, sizeof(buf), TR("sb_daemon_files_failed"), daemon_dir.c_str());
|
||||||
daemon_status_ = buf;
|
setDaemonStatus(buf);
|
||||||
DEBUG_LOGF("[ERROR] One or more daemon files failed to copy to %s\n", daemon_dir.c_str());
|
DEBUG_LOGF("[ERROR] One or more daemon files failed to copy to %s\n", daemon_dir.c_str());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -5311,21 +5354,22 @@ bool App::startEmbeddedDaemon()
|
|||||||
|
|
||||||
// Set up state callback
|
// Set up state callback
|
||||||
daemon_controller_->setStateCallback([this](daemon::EmbeddedDaemon::State state, const std::string& msg) {
|
daemon_controller_->setStateCallback([this](daemon::EmbeddedDaemon::State state, const std::string& msg) {
|
||||||
|
// NB: this callback runs on EmbeddedDaemon's monitor thread — write via the locked setter.
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case daemon::EmbeddedDaemon::State::Starting:
|
case daemon::EmbeddedDaemon::State::Starting:
|
||||||
daemon_status_ = TR("sb_starting_daemon");
|
setDaemonStatus(TR("sb_starting_daemon"));
|
||||||
break;
|
break;
|
||||||
case daemon::EmbeddedDaemon::State::Running:
|
case daemon::EmbeddedDaemon::State::Running:
|
||||||
daemon_status_ = TR("sb_dragonxd_running");
|
setDaemonStatus(TR("sb_dragonxd_running"));
|
||||||
break;
|
break;
|
||||||
case daemon::EmbeddedDaemon::State::Stopping:
|
case daemon::EmbeddedDaemon::State::Stopping:
|
||||||
daemon_status_ = TR("sb_dragonxd_stopping");
|
setDaemonStatus(TR("sb_dragonxd_stopping"));
|
||||||
break;
|
break;
|
||||||
case daemon::EmbeddedDaemon::State::Stopped:
|
case daemon::EmbeddedDaemon::State::Stopped:
|
||||||
daemon_status_ = TR("sb_dragonxd_stopped");
|
setDaemonStatus(TR("sb_dragonxd_stopped"));
|
||||||
break;
|
break;
|
||||||
case daemon::EmbeddedDaemon::State::Error:
|
case daemon::EmbeddedDaemon::State::Error:
|
||||||
daemon_status_ = "Error: " + msg;
|
setDaemonStatus("Error: " + msg);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -6958,7 +7002,7 @@ std::string App::buildDiagnosticsReport()
|
|||||||
<< (state_.warming_up ? " (warming up)" : "") << "\n";
|
<< (state_.warming_up ? " (warming up)" : "") << "\n";
|
||||||
|
|
||||||
#if !DRAGONX_LITE_BUILD
|
#if !DRAGONX_LITE_BUILD
|
||||||
os << "daemon status: " << daemon_status_ << "\n";
|
os << "daemon status: " << getDaemonStatus() << "\n";
|
||||||
if (daemon_controller_) {
|
if (daemon_controller_) {
|
||||||
os << "daemon running: " << (daemon_controller_->isRunning() ? "yes" : "no")
|
os << "daemon running: " << (daemon_controller_->isRunning() ? "yes" : "no")
|
||||||
<< ", crashes: " << daemon_controller_->crashCount() << "\n";
|
<< ", crashes: " << daemon_controller_->crashCount() << "\n";
|
||||||
|
|||||||
11
src/app.h
11
src/app.h
@@ -267,7 +267,7 @@ public:
|
|||||||
bool isConnected() const { return state_.connected; }
|
bool isConnected() const { return state_.connected; }
|
||||||
int getBlockHeight() const { return state_.sync.blocks; }
|
int getBlockHeight() const { return state_.sync.blocks; }
|
||||||
const std::string& getConnectionStatus() const { return connection_status_; }
|
const std::string& getConnectionStatus() const { return connection_status_; }
|
||||||
const std::string& getDaemonStatus() const { return daemon_status_; }
|
std::string getDaemonStatus() const; // thread-safe copy (daemon_status_ is written off-thread)
|
||||||
|
|
||||||
// Balance info (convenience wrappers)
|
// Balance info (convenience wrappers)
|
||||||
double getShieldedBalance() const { return state_.shielded_balance; }
|
double getShieldedBalance() const { return state_.shielded_balance; }
|
||||||
@@ -884,6 +884,7 @@ private:
|
|||||||
std::function<void(bool, const std::string&)> makeSweepCompletionCallback(bool resumed);
|
std::function<void(bool, const std::string&)> makeSweepCompletionCallback(bool resumed);
|
||||||
void beginSweepToSeedWallet(); // z_mergetoaddress ["ANY_TADDR","ANY_ZADDR"] -> dest
|
void beginSweepToSeedWallet(); // z_mergetoaddress ["ANY_TADDR","ANY_ZADDR"] -> dest
|
||||||
void pollSweepStatus(); // Confirming step: poll sweep confirmations + legacy balance
|
void pollSweepStatus(); // Confirming step: poll sweep confirmations + legacy balance
|
||||||
|
void locateSweepTxid(); // recover a lost sweep txid (send to dest) -> Confirming gate
|
||||||
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
|
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
|
||||||
void provisionChatIdentityFromSecret(std::string secret);
|
void provisionChatIdentityFromSecret(std::string secret);
|
||||||
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr — chat IDENTITY (reply-to)
|
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr — chat IDENTITY (reply-to)
|
||||||
@@ -1123,7 +1124,13 @@ private:
|
|||||||
|
|
||||||
// Embedded daemon state
|
// Embedded daemon state
|
||||||
bool use_embedded_daemon_ = wallet::supportsEmbeddedDaemon(wallet::currentWalletCapabilities());
|
bool use_embedded_daemon_ = wallet::supportsEmbeddedDaemon(wallet::currentWalletCapabilities());
|
||||||
std::string daemon_status_;
|
// Written by the DaemonController state callback (which fires on EmbeddedDaemon's monitor thread)
|
||||||
|
// AND read on the UI thread — so all access goes through set/getDaemonStatus() under this mutex.
|
||||||
|
// getDaemonStatus() returns a COPY: a returned reference could dangle if the monitor thread
|
||||||
|
// reassigned the string mid-read (data race / use-after-free).
|
||||||
|
mutable std::mutex daemon_status_mutex_;
|
||||||
|
std::string daemon_status_; // guarded by daemon_status_mutex_ — use set/getDaemonStatus()
|
||||||
|
void setDaemonStatus(std::string s);
|
||||||
mutable std::string daemon_mem_diag_; // diagnostic info for daemon memory detection
|
mutable std::string daemon_mem_diag_; // diagnostic info for daemon memory detection
|
||||||
size_t daemon_output_offset_ = 0; // for incremental output parsing (rescan detection)
|
size_t daemon_output_offset_ = 0; // for incremental output parsing (rescan detection)
|
||||||
|
|
||||||
|
|||||||
@@ -4701,6 +4701,9 @@ void App::showSeedMigrationDialog()
|
|||||||
seed_migration_balance_loaded_ = false;
|
seed_migration_balance_loaded_ = false;
|
||||||
seed_migration_nofunds_confirmed_ = false;
|
seed_migration_nofunds_confirmed_ = false;
|
||||||
refreshSeedMigrationBalance();
|
refreshSeedMigrationBalance();
|
||||||
|
// If a sweep was already broadcast (the marker survives a lost opid), try to locate its tx so
|
||||||
|
// we resume at the confirmation gate instead of risking a premature "no funds -> adopt".
|
||||||
|
if (settings_ && settings_->getSeedMigrationSweepSubmitted()) locateSweepTxid();
|
||||||
break;
|
break;
|
||||||
case MigrationResume::Intro:
|
case MigrationResume::Intro:
|
||||||
default:
|
default:
|
||||||
@@ -4768,6 +4771,12 @@ void App::beginSweepToSeedWallet()
|
|||||||
}
|
}
|
||||||
seed_migration_step_ = SeedMigrationStep::Sweeping;
|
seed_migration_step_ = SeedMigrationStep::Sweeping;
|
||||||
seed_migration_status_ = "Building the sweep transaction…";
|
seed_migration_status_ = "Building the sweep transaction…";
|
||||||
|
// Mark the sweep as submitted BEFORE broadcasting (persisted synchronously). If the app or daemon
|
||||||
|
// then dies after the tx reaches the mempool but before its opid/txid is persisted, a resumed
|
||||||
|
// migration still knows a sweep is in flight and won't mistake the resulting ~0 legacy balance for
|
||||||
|
// an empty wallet and adopt (swap wallet.dat) prematurely — funds in an unconfirmed sweep can still
|
||||||
|
// revert on a drop/reorg. Cleared below only if the submit definitively fails (nothing broadcast).
|
||||||
|
if (settings_) { settings_->setSeedMigrationSweepSubmitted(true); settings_->save(); }
|
||||||
const std::string dest = seed_migration_dest_;
|
const std::string dest = seed_migration_dest_;
|
||||||
worker_->post([this, dest]() -> rpc::RPCWorker::MainCb {
|
worker_->post([this, dest]() -> rpc::RPCWorker::MainCb {
|
||||||
std::string opid, err;
|
std::string opid, err;
|
||||||
@@ -4780,6 +4789,8 @@ void App::beginSweepToSeedWallet()
|
|||||||
} catch (const std::exception& e) { err = e.what(); }
|
} catch (const std::exception& e) { err = e.what(); }
|
||||||
return [this, opid, err]() {
|
return [this, opid, err]() {
|
||||||
if (!err.empty() || opid.empty()) {
|
if (!err.empty() || opid.empty()) {
|
||||||
|
// Submit failed — nothing was broadcast, so drop the "sweep in flight" marker.
|
||||||
|
if (settings_) { settings_->setSeedMigrationSweepSubmitted(false); settings_->save(); }
|
||||||
seed_migration_status_ = err.empty() ? "The sweep failed to start." : err;
|
seed_migration_status_ = err.empty() ? "The sweep failed to start." : err;
|
||||||
seed_migration_step_ = SeedMigrationStep::Error;
|
seed_migration_step_ = SeedMigrationStep::Error;
|
||||||
return;
|
return;
|
||||||
@@ -4882,6 +4893,69 @@ void App::pollSweepStatus()
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Best-effort recovery when a sweep was broadcast but its opid was lost (e.g. a daemon restart wiped
|
||||||
|
// the in-memory op queue) so we have no txid to confirm against. The sweep is an outgoing send to
|
||||||
|
// seed_migration_dest_; find it among the wallet's own sends (z_listsentbyaddress) and, if present,
|
||||||
|
// adopt its txid and resume at the confirmation gate (pollSweepStatus / Confirming). Read-only, and
|
||||||
|
// backstopped by that gate (confs >= 1 && legacy balance ~0), so a wrong match cannot lose funds —
|
||||||
|
// it only avoids the premature "no funds -> adopt" short-circuit while a sweep is unconfirmed.
|
||||||
|
void App::locateSweepTxid()
|
||||||
|
{
|
||||||
|
if (capture_mode_) return;
|
||||||
|
if (!rpc_ || !worker_ || !state_.connected) return;
|
||||||
|
if (seed_migration_confirm_in_flight_ || !seed_migration_sweep_txid_.empty()) return;
|
||||||
|
if (seed_migration_dest_.empty()) return;
|
||||||
|
seed_migration_confirm_in_flight_ = true;
|
||||||
|
const std::string dest = seed_migration_dest_;
|
||||||
|
worker_->post([this, dest]() -> rpc::RPCWorker::MainCb {
|
||||||
|
std::string found;
|
||||||
|
rpc::RPCClient::TraceScope trace("Migrate / locate sweep");
|
||||||
|
try {
|
||||||
|
// z_listsentbyaddress ignores its address argument and returns ALL wallet sends
|
||||||
|
// (oldest -> newest); the sweep is the latest send whose outputs include the destination.
|
||||||
|
auto sent = rpc_->call("z_listsentbyaddress", {dest, 0});
|
||||||
|
if (sent.is_array()) {
|
||||||
|
for (const auto& e : sent) {
|
||||||
|
if (!e.is_object() || !e.contains("sends") || !e["sends"].is_object()) continue;
|
||||||
|
const auto& s = e["sends"];
|
||||||
|
bool toDest = false;
|
||||||
|
for (const char* leg : {"saplingSends", "transparentSends"}) {
|
||||||
|
auto it = s.find(leg);
|
||||||
|
if (it == s.end() || !it->is_array()) continue;
|
||||||
|
for (const auto& o : *it) {
|
||||||
|
if (o.is_object() && o.value("address", std::string()) == dest) {
|
||||||
|
toDest = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (toDest) break;
|
||||||
|
}
|
||||||
|
if (toDest) {
|
||||||
|
auto t = e.value("txid", std::string());
|
||||||
|
if (!t.empty()) found = t; // keep scanning; entries are oldest->newest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (...) {}
|
||||||
|
return [this, found]() {
|
||||||
|
seed_migration_confirm_in_flight_ = false;
|
||||||
|
if (found.empty() || !seed_migration_sweep_txid_.empty()) return;
|
||||||
|
// Adopt the recovered txid and hand off to the confirmation gate (which alone permits adopt).
|
||||||
|
seed_migration_sweep_txid_ = found;
|
||||||
|
if (settings_) {
|
||||||
|
settings_->setSeedMigrationSweepTxid(found);
|
||||||
|
settings_->setSeedMigrationSweepOpid("");
|
||||||
|
settings_->save();
|
||||||
|
}
|
||||||
|
seed_migration_sweep_confs_ = 0;
|
||||||
|
seed_migration_legacy_remaining_ = -1.0;
|
||||||
|
seed_migration_poll_timer_ = 0.0f;
|
||||||
|
seed_migration_status_.clear();
|
||||||
|
seed_migration_step_ = SeedMigrationStep::Confirming;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 2 step 2: adopt the new seed wallet as the primary wallet. Stop the daemon, move the
|
// Phase 2 step 2: adopt the new seed wallet as the primary wallet. Stop the daemon, move the
|
||||||
// legacy wallet.dat aside to a timestamped backup (NEVER delete it), copy the new wallet in, and
|
// legacy wallet.dat aside to a timestamped backup (NEVER delete it), copy the new wallet in, and
|
||||||
// restart with -rescan so the new keys pick up the swept funds. Runs on a background thread.
|
// restart with -rescan so the new keys pick up the swept funds. Runs on a background thread.
|
||||||
@@ -5308,6 +5382,7 @@ void App::pumpSeedMigration()
|
|||||||
settings_->setSeedMigrationTempDir("");
|
settings_->setSeedMigrationTempDir("");
|
||||||
settings_->setSeedMigrationSweepTxid("");
|
settings_->setSeedMigrationSweepTxid("");
|
||||||
settings_->setSeedMigrationSweepOpid(""); // W3-3
|
settings_->setSeedMigrationSweepOpid(""); // W3-3
|
||||||
|
settings_->setSeedMigrationSweepSubmitted(false);
|
||||||
settings_->save();
|
settings_->save();
|
||||||
}
|
}
|
||||||
seed_migration_status_ = err; // a non-empty warning here (e.g. restart hiccup) is shown on Done
|
seed_migration_status_ = err; // a non-empty warning here (e.g. restart hiccup) is shown on Done
|
||||||
@@ -5355,6 +5430,7 @@ void App::pumpSeedMigration()
|
|||||||
// txid/opid (the resume block reads these whenever the migration is pending).
|
// txid/opid (the resume block reads these whenever the migration is pending).
|
||||||
settings_->setSeedMigrationSweepTxid("");
|
settings_->setSeedMigrationSweepTxid("");
|
||||||
settings_->setSeedMigrationSweepOpid("");
|
settings_->setSeedMigrationSweepOpid("");
|
||||||
|
settings_->setSeedMigrationSweepSubmitted(false);
|
||||||
settings_->save();
|
settings_->save();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ bool Settings::load(const std::string& path)
|
|||||||
loadScalar(j, "seed_migration_temp_dir", seed_migration_temp_dir_);
|
loadScalar(j, "seed_migration_temp_dir", seed_migration_temp_dir_);
|
||||||
loadScalar(j, "seed_migration_sweep_txid", seed_migration_sweep_txid_);
|
loadScalar(j, "seed_migration_sweep_txid", seed_migration_sweep_txid_);
|
||||||
loadScalar(j, "seed_migration_sweep_opid", seed_migration_sweep_opid_);
|
loadScalar(j, "seed_migration_sweep_opid", seed_migration_sweep_opid_);
|
||||||
|
loadScalar(j, "seed_migration_sweep_submitted", seed_migration_sweep_submitted_);
|
||||||
loadScalar(j, "auto_lock_timeout", auto_lock_timeout_);
|
loadScalar(j, "auto_lock_timeout", auto_lock_timeout_);
|
||||||
loadScalar(j, "unlock_duration", unlock_duration_);
|
loadScalar(j, "unlock_duration", unlock_duration_);
|
||||||
loadScalar(j, "pin_enabled", pin_enabled_);
|
loadScalar(j, "pin_enabled", pin_enabled_);
|
||||||
@@ -559,6 +560,7 @@ bool Settings::save(const std::string& path)
|
|||||||
j["seed_migration_temp_dir"] = seed_migration_temp_dir_;
|
j["seed_migration_temp_dir"] = seed_migration_temp_dir_;
|
||||||
j["seed_migration_sweep_txid"] = seed_migration_sweep_txid_;
|
j["seed_migration_sweep_txid"] = seed_migration_sweep_txid_;
|
||||||
j["seed_migration_sweep_opid"] = seed_migration_sweep_opid_;
|
j["seed_migration_sweep_opid"] = seed_migration_sweep_opid_;
|
||||||
|
j["seed_migration_sweep_submitted"] = seed_migration_sweep_submitted_;
|
||||||
j["auto_lock_timeout"] = auto_lock_timeout_;
|
j["auto_lock_timeout"] = auto_lock_timeout_;
|
||||||
j["unlock_duration"] = unlock_duration_;
|
j["unlock_duration"] = unlock_duration_;
|
||||||
j["pin_enabled"] = pin_enabled_;
|
j["pin_enabled"] = pin_enabled_;
|
||||||
|
|||||||
@@ -396,6 +396,12 @@ public:
|
|||||||
// in the same write that persists the txid, so the txid always outranks it (see [[decideSeedMigrationResume]]).
|
// in the same write that persists the txid, so the txid always outranks it (see [[decideSeedMigrationResume]]).
|
||||||
std::string getSeedMigrationSweepOpid() const { return seed_migration_sweep_opid_; }
|
std::string getSeedMigrationSweepOpid() const { return seed_migration_sweep_opid_; }
|
||||||
void setSeedMigrationSweepOpid(const std::string& v) { seed_migration_sweep_opid_ = v; }
|
void setSeedMigrationSweepOpid(const std::string& v) { seed_migration_sweep_opid_ = v; }
|
||||||
|
// Set TRUE synchronously when a sweep is broadcast; survives the stale-opid resume fallback so the
|
||||||
|
// Sweep step knows a ~0 legacy balance may be an UNCONFIRMED in-flight sweep (funds not yet safe)
|
||||||
|
// and must NOT short-circuit to adopt/swap wallet.dat. Cleared on confirmed-adopt / reset / a
|
||||||
|
// failed submit. See [[decideSeedMigrationResume]] and App::renderSeedMigrationDialog (fund-safety).
|
||||||
|
bool getSeedMigrationSweepSubmitted() const { return seed_migration_sweep_submitted_; }
|
||||||
|
void setSeedMigrationSweepSubmitted(bool v) { seed_migration_sweep_submitted_ = v; }
|
||||||
|
|
||||||
// Security — auto-lock timeout (seconds; 0 = disabled)
|
// Security — auto-lock timeout (seconds; 0 = disabled)
|
||||||
int getAutoLockTimeout() const { return auto_lock_timeout_; }
|
int getAutoLockTimeout() const { return auto_lock_timeout_; }
|
||||||
@@ -640,6 +646,7 @@ private:
|
|||||||
std::string seed_migration_temp_dir_;
|
std::string seed_migration_temp_dir_;
|
||||||
std::string seed_migration_sweep_txid_;
|
std::string seed_migration_sweep_txid_;
|
||||||
std::string seed_migration_sweep_opid_;
|
std::string seed_migration_sweep_opid_;
|
||||||
|
bool seed_migration_sweep_submitted_ = false;
|
||||||
int auto_lock_timeout_ = 900; // 15 minutes
|
int auto_lock_timeout_ = 900; // 15 minutes
|
||||||
int unlock_duration_ = 600; // 10 minutes
|
int unlock_duration_ = 600; // 10 minutes
|
||||||
bool pin_enabled_ = false;
|
bool pin_enabled_ = false;
|
||||||
|
|||||||
@@ -34,4 +34,12 @@ inline MigrationResume decideSeedMigrationResume(bool pending,
|
|||||||
return MigrationResume::SweepGate;
|
return MigrationResume::SweepGate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// At the Sweep step with a ~0 legacy balance, may we offer the direct "no funds -> adopt" short-cut
|
||||||
|
// (swap wallet.dat immediately)? Only when NO sweep has been submitted — a genuinely empty wallet.
|
||||||
|
// If a sweep WAS submitted, a ~0 balance is ambiguous: the sweep tx may be unconfirmed in the mempool
|
||||||
|
// and could revert on a drop/reorg, so adopting now can strand the funds in the moved-aside backup.
|
||||||
|
// In that case adopt must go through the confirmation-gated path (>=1 conf) instead — never swap
|
||||||
|
// wallet.dat on an unconfirmed sweep. (fund-safety: audit)
|
||||||
|
inline bool sweepGateMayAdoptEmptyWallet(bool sweepSubmitted) { return !sweepSubmitted; }
|
||||||
|
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
@@ -149,7 +149,13 @@ struct SyncInfo {
|
|||||||
float witness_progress = 0.0f; // 0.0 - 1.0, within the current sub-phase
|
float witness_progress = 0.0f; // 0.0 - 1.0, within the current sub-phase
|
||||||
int witness_remaining = 0; // blocks left in the cache walk (0 if unknown / phase 1)
|
int witness_remaining = 0; // blocks left in the cache walk (0 if unknown / phase 1)
|
||||||
|
|
||||||
bool isSynced() const { return !syncing && blocks > 0 && blocks >= headers - 2; }
|
// True only when the daemon reports a positive peer-derived tip (longestchain > 0). A node with
|
||||||
|
// no peers reports longestchain == 0, so `blocks` is then only the LOCAL best height and cannot be
|
||||||
|
// trusted as "at the network tip". isSynced() gates on this so a peerless / cold-started node is
|
||||||
|
// never reported synced (which would enable Send against a stale balance). (audit: peerless-synced)
|
||||||
|
bool tip_known = false;
|
||||||
|
|
||||||
|
bool isSynced() const { return tip_known && !syncing && blocks > 0 && blocks >= headers - 2; }
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -853,8 +853,11 @@ void NetworkRefreshService::appendViewTransactionOutputs(std::vector<Transaction
|
|||||||
for (const auto& out : entry.outgoing_outputs) {
|
for (const auto& out : entry.outgoing_outputs) {
|
||||||
bool alreadyTracked = false;
|
bool alreadyTracked = false;
|
||||||
for (const auto& existing : transactions) {
|
for (const auto& existing : transactions) {
|
||||||
|
// Key on address as well as amount: two outgoing outputs of EQUAL value to DIFFERENT
|
||||||
|
// recipients in one tx are distinct rows — an amount-only match would drop the second.
|
||||||
if (existing.txid == txid && existing.type == "send" &&
|
if (existing.txid == txid && existing.type == "send" &&
|
||||||
std::abs(existing.amount + out.value) < 0.00000001) {
|
std::abs(existing.amount + out.value) < 0.00000001 &&
|
||||||
|
existing.address == out.address) {
|
||||||
alreadyTracked = true;
|
alreadyTracked = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -885,6 +888,55 @@ void NetworkRefreshService::appendViewTransactionOutputs(std::vector<Transaction
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void NetworkRefreshService::appendSentTransparentOutputs(std::vector<TransactionInfo>& transactions,
|
||||||
|
const std::string& txid,
|
||||||
|
const nlohmann::json& sentEntry)
|
||||||
|
{
|
||||||
|
if (!sentEntry.is_object() || !sentEntry.contains("sends") || !sentEntry["sends"].is_object())
|
||||||
|
return;
|
||||||
|
const auto& sends = sentEntry["sends"];
|
||||||
|
auto it = sends.find("transparentSends");
|
||||||
|
if (it == sends.end() || !it->is_array()) return;
|
||||||
|
|
||||||
|
// Per-tx metadata for the synthesized rows.
|
||||||
|
int confirmations = 0;
|
||||||
|
std::int64_t timestamp = 0;
|
||||||
|
if (auto v = readOptional<int>(sentEntry, "confirmations")) confirmations = *v;
|
||||||
|
if (auto v = readOptional<std::int64_t>(sentEntry, "blocktime")) timestamp = *v;
|
||||||
|
if (timestamp == 0) {
|
||||||
|
if (auto v = readOptional<std::int64_t>(sentEntry, "time")) timestamp = *v;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto& out : *it) {
|
||||||
|
auto amount = readOptional<double>(out, "amount");
|
||||||
|
if (!amount) continue;
|
||||||
|
const double debit = -std::abs(*amount); // the daemon reports -nValue; a send is a debit
|
||||||
|
std::string address;
|
||||||
|
if (auto a = readOptional<std::string>(out, "address")) address = *a;
|
||||||
|
|
||||||
|
// Dedup against rows already present — notably listtransactions-derived t->t sends, which
|
||||||
|
// this whole-wallet payload ALSO reports. Same txid+address+amount == the same output.
|
||||||
|
bool exists = false;
|
||||||
|
for (const auto& e : transactions) {
|
||||||
|
if (e.txid == txid && e.type == "send" && e.address == address &&
|
||||||
|
std::abs(e.amount - debit) < 0.00000001) {
|
||||||
|
exists = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (exists) continue;
|
||||||
|
|
||||||
|
TransactionInfo info;
|
||||||
|
info.txid = txid;
|
||||||
|
info.type = "send";
|
||||||
|
info.address = address;
|
||||||
|
info.amount = debit;
|
||||||
|
info.confirmations = confirmations;
|
||||||
|
info.timestamp = timestamp;
|
||||||
|
transactions.push_back(std::move(info));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void NetworkRefreshService::sortTransactionsNewestFirst(std::vector<TransactionInfo>& transactions)
|
void NetworkRefreshService::sortTransactionsNewestFirst(std::vector<TransactionInfo>& transactions)
|
||||||
{
|
{
|
||||||
std::sort(transactions.begin(), transactions.end(),
|
std::sort(transactions.begin(), transactions.end(),
|
||||||
@@ -985,6 +1037,45 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
|||||||
result.shieldedScanComplete = !pendingShieldedIndex.has_value();
|
result.shieldedScanComplete = !pendingShieldedIndex.has_value();
|
||||||
result.nextShieldedScanStartIndex = pendingShieldedIndex.value_or(0);
|
result.nextShieldedScanStartIndex = pendingShieldedIndex.value_or(0);
|
||||||
|
|
||||||
|
// Discover outgoing shielded (z->z) sends. A pure z->z send moves no value in or out of the
|
||||||
|
// transparent pool (valueBalance == 0), so the daemon's `listtransactions` never reports it,
|
||||||
|
// and — being a send rather than a receive — it never shows up in `z_listreceivedbyaddress`
|
||||||
|
// either. Its txid therefore never reaches `knownTxids`, so the z_viewtransaction enrichment
|
||||||
|
// below is never run for it and the send is invisible in History (the reported bug).
|
||||||
|
//
|
||||||
|
// `z_listsentbyaddress` scans the whole wallet for sends — the address argument is NOT used to
|
||||||
|
// filter (the daemon iterates every wallet tx against "*"), so a single call yields all send
|
||||||
|
// txids. Two legs need handling:
|
||||||
|
// * SHIELDED (z->z) outgoing legs are surfaced by z_viewtransaction, so we fold the txid into
|
||||||
|
// `knownTxids` and let the z_viewtransaction / appendViewTransactionOutputs path build them.
|
||||||
|
// * TRANSPARENT outgoing legs (z->t deshields) are NOT surfaced by z_viewtransaction (it
|
||||||
|
// enumerates only Sapling outputs), and for a z-originated send listtransactions also omits
|
||||||
|
// the external t-vout (GetAmounts' isFromMyTaddr is false when inputs are shielded). So we
|
||||||
|
// build those rows directly from the payload's `sends.transparentSends[]` here. They are
|
||||||
|
// deduped (by txid+address+amount) against listtransactions-derived t->t sends above, so a
|
||||||
|
// purely-transparent (t->t) send — which the payload ALSO reports — is not double-counted.
|
||||||
|
//
|
||||||
|
// Gated on shieldedScanComplete so it adds no cost during the multi-cycle initial received
|
||||||
|
// scan: it is one more O(mapWallet) pass holding cs_main (comparable to the listtransactions
|
||||||
|
// pass), and the caller's adaptive throttle (txRefreshDue) already accounts for it via scanMs.
|
||||||
|
if (result.shieldedScanComplete && !snapshot.shieldedAddresses.empty()) {
|
||||||
|
try {
|
||||||
|
json sent = rpc.call("z_listsentbyaddress",
|
||||||
|
json::array({snapshot.shieldedAddresses.front(), 0}));
|
||||||
|
if (sent.is_array()) {
|
||||||
|
for (const auto& sentTx : sent) {
|
||||||
|
auto txid = readOptional<std::string>(sentTx, "txid");
|
||||||
|
if (!txid || txid->empty()) continue;
|
||||||
|
appendSentTransparentOutputs(result.transactions, *txid, sentTx);
|
||||||
|
knownTxids.insert(*txid); // shielded (z->z) legs enriched via z_viewtransaction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
transactionRpcError = true;
|
||||||
|
DEBUG_LOGF("z_listsentbyaddress error: %s\n", e.what());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const auto& txid : snapshot.sendTxids) {
|
for (const auto& txid : snapshot.sendTxids) {
|
||||||
knownTxids.insert(txid);
|
knownTxids.insert(txid);
|
||||||
}
|
}
|
||||||
@@ -1305,6 +1396,10 @@ void NetworkRefreshService::applyCoreRefreshResult(WalletState& state,
|
|||||||
if (result.headers) state.sync.headers = *result.headers;
|
if (result.headers) state.sync.headers = *result.headers;
|
||||||
if (result.bestBlockHash) state.sync.best_blockhash = *result.bestBlockHash;
|
if (result.bestBlockHash) state.sync.best_blockhash = *result.bestBlockHash;
|
||||||
if (result.verificationProgress) state.sync.verification_progress = *result.verificationProgress;
|
if (result.verificationProgress) state.sync.verification_progress = *result.verificationProgress;
|
||||||
|
// A peerless daemon reports longestchain == 0 (no peer heights). Track that as "tip unknown" from
|
||||||
|
// THIS refresh's raw value (not the retained state, which keeps the last positive value) so a
|
||||||
|
// peerless node isn't reported synced against its own stale local height. (audit: peerless-synced)
|
||||||
|
state.sync.tip_known = (result.longestChain.has_value() && *result.longestChain > 0);
|
||||||
if (result.longestChain && *result.longestChain > 0) state.longestchain = *result.longestChain;
|
if (result.longestChain && *result.longestChain > 0) state.longestchain = *result.longestChain;
|
||||||
if (state.longestchain > 0 && state.sync.blocks > state.longestchain) state.longestchain = state.sync.blocks;
|
if (state.longestchain > 0 && state.sync.blocks > state.longestchain) state.longestchain = state.sync.blocks;
|
||||||
if (state.longestchain > 0)
|
if (state.longestchain > 0)
|
||||||
|
|||||||
@@ -310,6 +310,12 @@ public:
|
|||||||
static void appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
static void appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
||||||
const std::string& txid,
|
const std::string& txid,
|
||||||
const TransactionViewCacheEntry& entry);
|
const TransactionViewCacheEntry& entry);
|
||||||
|
// Build "send" rows from a z_listsentbyaddress entry's sends.transparentSends[] — the transparent
|
||||||
|
// (z->t deshield) outgoing outputs that z_viewtransaction does not surface. Deduped by
|
||||||
|
// txid+address+amount against rows already present (e.g. listtransactions-derived t->t sends).
|
||||||
|
static void appendSentTransparentOutputs(std::vector<TransactionInfo>& transactions,
|
||||||
|
const std::string& txid,
|
||||||
|
const nlohmann::json& sentEntry);
|
||||||
static void sortTransactionsNewestFirst(std::vector<TransactionInfo>& transactions);
|
static void sortTransactionsNewestFirst(std::vector<TransactionInfo>& transactions);
|
||||||
static TransactionRefreshResult collectTransactionRefreshResult(RefreshRpcGateway& rpc,
|
static TransactionRefreshResult collectTransactionRefreshResult(RefreshRpcGateway& rpc,
|
||||||
const TransactionRefreshSnapshot& snapshot,
|
const TransactionRefreshSnapshot& snapshot,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ enum class NodeBannerReason {
|
|||||||
FullNodeOffline, // a reachable node was lost, or never came up; reconnect offered
|
FullNodeOffline, // a reachable node was lost, or never came up; reconnect offered
|
||||||
DaemonCrashed, // the embedded daemon crashed repeatedly and auto-restart stopped
|
DaemonCrashed, // the embedded daemon crashed repeatedly and auto-restart stopped
|
||||||
LiteOpenFailed, // lite build: the wallet failed to open
|
LiteOpenFailed, // lite build: the wallet failed to open
|
||||||
|
NoPeers, // connected to the daemon but no peer-derived tip (0 peers) — stalled, stale
|
||||||
};
|
};
|
||||||
|
|
||||||
struct NodeBannerState {
|
struct NodeBannerState {
|
||||||
@@ -50,6 +51,13 @@ struct NodeBannerInputs {
|
|||||||
bool daemon_initializing = false; // daemon launching / block index loading
|
bool daemon_initializing = false; // daemon launching / block index loading
|
||||||
bool connection_in_progress = false; // a connect attempt is actively running
|
bool connection_in_progress = false; // a connect attempt is actively running
|
||||||
|
|
||||||
|
// Peer connectivity: a full node connected to the daemon but with NO peer-derived tip
|
||||||
|
// (longestchain == 0 -> tip_known false) has no peers, so it silently stalls at a stale height
|
||||||
|
// while balance/sync look "done". `blocks` gates the check so we don't flag the brief window
|
||||||
|
// before the first chain-info refresh. (audit: no-peer-banner)
|
||||||
|
bool tip_known = true; // sync.tip_known — a peer-derived network tip is known
|
||||||
|
int blocks = 0; // sync.blocks — >0 once chain info has been fetched
|
||||||
|
|
||||||
// Full-node embedded-daemon crash signal.
|
// Full-node embedded-daemon crash signal.
|
||||||
bool using_embedded_daemon = false;
|
bool using_embedded_daemon = false;
|
||||||
bool has_daemon_controller = false;
|
bool has_daemon_controller = false;
|
||||||
@@ -81,13 +89,26 @@ inline NodeBannerState evaluateNodeStatusBanner(const NodeBannerInputs& in) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full node. Connected, or in an expected startup phase → the loading/warmup overlay owns
|
// Full node. In an expected startup phase → the loading/warmup overlay owns the screen, so no
|
||||||
// the screen, so no banner. An active connect attempt likewise shows progress, not an
|
// banner. An active connect attempt likewise shows progress, not an error — don't flicker a
|
||||||
// error — don't flicker a banner over it.
|
// banner over it. (These return even if `connected` has flipped early, matching prior behavior.)
|
||||||
if (in.connected) return s;
|
|
||||||
if (in.warming_up || in.daemon_initializing) return s;
|
if (in.warming_up || in.daemon_initializing) return s;
|
||||||
if (in.connection_in_progress) return s;
|
if (in.connection_in_progress) return s;
|
||||||
|
|
||||||
|
// Connected to the daemon (RPC works) but with no peer-derived tip → the node has no peers and
|
||||||
|
// cannot learn about new blocks; it silently stalls at a stale height while everything looks
|
||||||
|
// synced. Surface it as a recoverable warning (reconnect won't help, so no action button).
|
||||||
|
if (in.connected) {
|
||||||
|
if (!in.tip_known && in.blocks > 0) {
|
||||||
|
s.show = true;
|
||||||
|
s.severity = NodeBannerSeverity::Warning;
|
||||||
|
s.reason = NodeBannerReason::NoPeers;
|
||||||
|
s.action = NodeBannerAction::None;
|
||||||
|
s.detail = in.connection_status;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
// Genuinely offline. Distinguish "the embedded daemon crashed and we stopped retrying" (a
|
// Genuinely offline. Distinguish "the embedded daemon crashed and we stopped retrying" (a
|
||||||
// hard fault needing a manual restart) from an ordinary lost/failed connection (retryable).
|
// hard fault needing a manual restart) from an ordinary lost/failed connection (retryable).
|
||||||
if (in.using_embedded_daemon && in.has_daemon_controller && !in.daemon_running &&
|
if (in.using_embedded_daemon && in.has_daemon_controller && !in.daemon_running &&
|
||||||
|
|||||||
@@ -425,6 +425,9 @@ void I18n::loadBuiltinEnglish()
|
|||||||
// Sweep step: balance still loading / no funds to move
|
// Sweep step: balance still loading / no funds to move
|
||||||
strings_["mig_checking_balance"] = "Checking your balance";
|
strings_["mig_checking_balance"] = "Checking your balance";
|
||||||
strings_["mig_no_funds"] = "Your wallet has no funds to migrate. You can adopt the new seed wallet directly — it replaces your current (empty) wallet with the seed-backed one. Your old wallet is still moved aside to a timestamped backup, just in case.";
|
strings_["mig_no_funds"] = "Your wallet has no funds to migrate. You can adopt the new seed wallet directly — it replaces your current (empty) wallet with the seed-backed one. Your old wallet is still moved aside to a timestamped backup, just in case.";
|
||||||
|
strings_["mig_sweep_inflight"] = "A sweep was already started and hasn't confirmed yet. Your balance reads zero because the funds are in the pending sweep transaction — they are not lost. Wait for it to confirm before finishing; the wallet will not be swapped until the sweep is mined.";
|
||||||
|
strings_["mig_checking_sweep"] = "Looking for the sweep transaction";
|
||||||
|
strings_["mig_check_sweep"] = "Check the sweep";
|
||||||
strings_["mig_adopt_now"] = "Adopt seed wallet";
|
strings_["mig_adopt_now"] = "Adopt seed wallet";
|
||||||
strings_["mig_nofunds_confirm"] = "I understand this replaces my current wallet with the new seed wallet";
|
strings_["mig_nofunds_confirm"] = "I understand this replaces my current wallet with the new seed wallet";
|
||||||
// Adopting step note (shown under the spinner while the node restarts + rescans)
|
// Adopting step note (shown under the spinner while the node restarts + rescans)
|
||||||
@@ -1459,6 +1462,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["sb_wallet_needs_recovery"] = "Wallet repair available";
|
strings_["sb_wallet_needs_recovery"] = "Wallet repair available";
|
||||||
// Persistent node-status banner (App::renderNodeStatusBanner).
|
// Persistent node-status banner (App::renderNodeStatusBanner).
|
||||||
strings_["node_banner_offline_title"] = "Not connected to the DragonX node";
|
strings_["node_banner_offline_title"] = "Not connected to the DragonX node";
|
||||||
|
strings_["node_banner_no_peers_title"] = "Connected, but no network peers — the node can't sync";
|
||||||
strings_["node_banner_crashed_title"] = "The node stopped unexpectedly";
|
strings_["node_banner_crashed_title"] = "The node stopped unexpectedly";
|
||||||
strings_["node_banner_lite_open_failed"] = "Couldn't open your wallet";
|
strings_["node_banner_lite_open_failed"] = "Couldn't open your wallet";
|
||||||
strings_["node_banner_reconnect"] = "Reconnect";
|
strings_["node_banner_reconnect"] = "Reconnect";
|
||||||
|
|||||||
@@ -450,31 +450,47 @@ void LiteWalletController::pumpAsyncOpen()
|
|||||||
bool LiteWalletController::beginCreateWalletAsync(LiteWalletCreateRequest request)
|
bool LiteWalletController::beginCreateWalletAsync(LiteWalletCreateRequest request)
|
||||||
{
|
{
|
||||||
auto req = std::make_shared<LiteWalletCreateRequest>(std::move(request));
|
auto req = std::make_shared<LiteWalletCreateRequest>(std::move(request));
|
||||||
return beginLifecycleRequestAsync(
|
// Encrypt the brand-new wallet with the user's passphrase once it opens. The async worker
|
||||||
|
// can't (walletOpen_ isn't set until pumpLifecycleResult runs), so stash it and apply on the
|
||||||
|
// main-thread finalize — otherwise the passphrase is discarded and the seed stored in
|
||||||
|
// plaintext (audit C1). Mirrors the synchronous createWallet() wrapper.
|
||||||
|
stashPendingLifecycleSecurity(req->passphrase, PendingSecurityOp::Encrypt);
|
||||||
|
const bool started = beginLifecycleRequestAsync(
|
||||||
"Create",
|
"Create",
|
||||||
[req](LiteWalletLifecycleService& svc, const std::string& url) {
|
[req](LiteWalletLifecycleService& svc, const std::string& url) {
|
||||||
req->serverUrl = url;
|
req->serverUrl = url;
|
||||||
return svc.createWallet(*req);
|
return svc.createWallet(*req);
|
||||||
},
|
},
|
||||||
[req]() { secureWipeLiteSecret(req->passphrase); });
|
[req]() { secureWipeLiteSecret(req->passphrase); });
|
||||||
|
if (!started) clearPendingLifecycleSecurity();
|
||||||
|
return started;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool LiteWalletController::beginOpenWalletAsync(LiteWalletOpenRequest request)
|
bool LiteWalletController::beginOpenWalletAsync(LiteWalletOpenRequest request)
|
||||||
{
|
{
|
||||||
auto req = std::make_shared<LiteWalletOpenRequest>(std::move(request));
|
auto req = std::make_shared<LiteWalletOpenRequest>(std::move(request));
|
||||||
return beginLifecycleRequestAsync(
|
// An existing wallet may be encrypted+locked — unlock it with the supplied passphrase on the
|
||||||
|
// main-thread finalize (the async worker can't; walletOpen_ isn't set yet). Without this an
|
||||||
|
// encrypted wallet opens but stays locked and the passphrase is never verified (audit M1).
|
||||||
|
stashPendingLifecycleSecurity(req->passphrase, PendingSecurityOp::Unlock);
|
||||||
|
const bool started = beginLifecycleRequestAsync(
|
||||||
"Open",
|
"Open",
|
||||||
[req](LiteWalletLifecycleService& svc, const std::string& url) {
|
[req](LiteWalletLifecycleService& svc, const std::string& url) {
|
||||||
req->serverUrl = url;
|
req->serverUrl = url;
|
||||||
return svc.openWallet(*req);
|
return svc.openWallet(*req);
|
||||||
},
|
},
|
||||||
[req]() { secureWipeLiteSecret(req->passphrase); });
|
[req]() { secureWipeLiteSecret(req->passphrase); });
|
||||||
|
if (!started) clearPendingLifecycleSecurity();
|
||||||
|
return started;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool LiteWalletController::beginRestoreWalletAsync(LiteWalletRestoreRequest request)
|
bool LiteWalletController::beginRestoreWalletAsync(LiteWalletRestoreRequest request)
|
||||||
{
|
{
|
||||||
auto req = std::make_shared<LiteWalletRestoreRequest>(std::move(request));
|
auto req = std::make_shared<LiteWalletRestoreRequest>(std::move(request));
|
||||||
return beginLifecycleRequestAsync(
|
// Encrypt the restored wallet with the user's passphrase once it opens (same rationale and
|
||||||
|
// main-thread finalize as create — audit C1).
|
||||||
|
stashPendingLifecycleSecurity(req->passphrase, PendingSecurityOp::Encrypt);
|
||||||
|
const bool started = beginLifecycleRequestAsync(
|
||||||
"Restore",
|
"Restore",
|
||||||
[req](LiteWalletLifecycleService& svc, const std::string& url) {
|
[req](LiteWalletLifecycleService& svc, const std::string& url) {
|
||||||
req->serverUrl = url;
|
req->serverUrl = url;
|
||||||
@@ -484,6 +500,8 @@ bool LiteWalletController::beginRestoreWalletAsync(LiteWalletRestoreRequest requ
|
|||||||
secureWipeLiteSecret(req->seedPhrase);
|
secureWipeLiteSecret(req->seedPhrase);
|
||||||
secureWipeLiteSecret(req->passphrase);
|
secureWipeLiteSecret(req->passphrase);
|
||||||
});
|
});
|
||||||
|
if (!started) clearPendingLifecycleSecurity();
|
||||||
|
return started;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool LiteWalletController::beginLifecycleRequestAsync(
|
bool LiteWalletController::beginLifecycleRequestAsync(
|
||||||
@@ -585,15 +603,54 @@ void LiteWalletController::pumpLifecycleResult()
|
|||||||
// log the failure otherwise (shared with the synchronous lifecycle path).
|
// log the failure otherwise (shared with the synchronous lifecycle path).
|
||||||
onLifecycleResult(out);
|
onLifecycleResult(out);
|
||||||
if (out.walletReady) {
|
if (out.walletReady) {
|
||||||
|
// Wallet is now open (walletOpen_ set by onLifecycleResult) — apply the stashed passphrase:
|
||||||
|
// encrypt a freshly created/restored wallet, or unlock an existing encrypted one (C1/M1).
|
||||||
|
applyPendingLifecycleSecurity();
|
||||||
lastOpenError_.clear();
|
lastOpenError_.clear();
|
||||||
lastOpenWarming_ = false;
|
lastOpenWarming_ = false;
|
||||||
} else {
|
} else {
|
||||||
|
clearPendingLifecycleSecurity(); // wallet didn't open — drop the stashed passphrase
|
||||||
lastOpenError_ = out.error.empty() ? out.status.message : out.error;
|
lastOpenError_ = out.error.empty() ? out.status.message : out.error;
|
||||||
lastOpenWarming_ = liteOpenErrorIsWarmup(lastOpenError_);
|
lastOpenWarming_ = liteOpenErrorIsWarmup(lastOpenError_);
|
||||||
}
|
}
|
||||||
lastLifecycleResult_ = std::move(out);
|
lastLifecycleResult_ = std::move(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void LiteWalletController::stashPendingLifecycleSecurity(const std::string& passphrase,
|
||||||
|
PendingSecurityOp op)
|
||||||
|
{
|
||||||
|
clearPendingLifecycleSecurity(); // wipe any stale prior stash first
|
||||||
|
pendingLifecyclePassphrase_ = passphrase; // independent copy; wiped in apply/clear
|
||||||
|
pendingLifecycleSecurityOp_ = op;
|
||||||
|
}
|
||||||
|
|
||||||
|
void LiteWalletController::applyPendingLifecycleSecurity()
|
||||||
|
{
|
||||||
|
const PendingSecurityOp op = pendingLifecycleSecurityOp_;
|
||||||
|
if (op == PendingSecurityOp::None || pendingLifecyclePassphrase_.empty() ||
|
||||||
|
!walletOpen_.load() || !bridge_) {
|
||||||
|
clearPendingLifecycleSecurity();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (op == PendingSecurityOp::Encrypt) {
|
||||||
|
// encryptWallet() takes its own copy and wipes it; the backend encrypts + locks + saves.
|
||||||
|
const auto enc = encryptWallet(pendingLifecyclePassphrase_);
|
||||||
|
if (!enc.ok)
|
||||||
|
liteLog("wallet created/restored but ENCRYPTION FAILED — wallet is NOT encrypted: " +
|
||||||
|
enc.error);
|
||||||
|
} else if (op == PendingSecurityOp::Unlock) {
|
||||||
|
if (!unlockWallet(pendingLifecyclePassphrase_))
|
||||||
|
liteLog("wallet opened but unlock failed (wrong passphrase?)");
|
||||||
|
}
|
||||||
|
clearPendingLifecycleSecurity();
|
||||||
|
}
|
||||||
|
|
||||||
|
void LiteWalletController::clearPendingLifecycleSecurity()
|
||||||
|
{
|
||||||
|
secureWipeLiteSecret(pendingLifecyclePassphrase_); // no-op if already empty
|
||||||
|
pendingLifecycleSecurityOp_ = PendingSecurityOp::None;
|
||||||
|
}
|
||||||
|
|
||||||
void LiteWalletController::startSync()
|
void LiteWalletController::startSync()
|
||||||
{
|
{
|
||||||
if (syncLaunched_.exchange(true)) return;
|
if (syncLaunched_.exchange(true)) return;
|
||||||
|
|||||||
@@ -388,6 +388,20 @@ private:
|
|||||||
std::make_shared<std::optional<LiteWalletLifecycleResult>>();
|
std::make_shared<std::optional<LiteWalletLifecycleResult>>();
|
||||||
LiteWalletLifecycleResult lastLifecycleResult_; // main-thread only; last finalized request
|
LiteWalletLifecycleResult lastLifecycleResult_; // main-thread only; last finalized request
|
||||||
|
|
||||||
|
// Passphrase to apply on the main thread once an async create/restore/open finalizes. The
|
||||||
|
// backend wallet is not controller-"open" (walletOpen_) until pumpLifecycleResult() runs
|
||||||
|
// onLifecycleResult(), and encryptWallet()/unlockWallet() gate on walletOpen_ — so the async
|
||||||
|
// worker thread cannot do it. The passphrase is stashed here (main-thread only) and applied in
|
||||||
|
// pumpLifecycleResult(), mirroring the synchronous createWallet()/restoreWallet()/openWallet()
|
||||||
|
// wrappers. Without this the UI's async path silently discarded the passphrase, storing the
|
||||||
|
// seed UNENCRYPTED (create/restore) or never unlocking an encrypted wallet (open) — audit C1/M1.
|
||||||
|
enum class PendingSecurityOp { None, Encrypt, Unlock };
|
||||||
|
std::string pendingLifecyclePassphrase_; // main-thread only; wiped after use
|
||||||
|
PendingSecurityOp pendingLifecycleSecurityOp_ = PendingSecurityOp::None;
|
||||||
|
void stashPendingLifecycleSecurity(const std::string& passphrase, PendingSecurityOp op);
|
||||||
|
void applyPendingLifecycleSecurity(); // encrypt (create/restore) or unlock (open), then wipe
|
||||||
|
void clearPendingLifecycleSecurity(); // wipe + reset (rejected request / failed open)
|
||||||
|
|
||||||
// Joinable background refresh worker (fast iterations: syncstatus, plus data once synced).
|
// Joinable background refresh worker (fast iterations: syncstatus, plus data once synced).
|
||||||
std::thread worker_;
|
std::thread worker_;
|
||||||
std::atomic<bool> running_{false};
|
std::atomic<bool> running_{false};
|
||||||
|
|||||||
@@ -1628,6 +1628,7 @@ void testNetworkRefreshRpcCollectors()
|
|||||||
{"time", 50}, {"memoStr", "shielded memo"}}
|
{"time", 50}, {"memoStr", "shielded memo"}}
|
||||||
}));
|
}));
|
||||||
transactionRpc.addResponse("z_listreceivedbyaddress", json::array());
|
transactionRpc.addResponse("z_listreceivedbyaddress", json::array());
|
||||||
|
transactionRpc.addResponse("z_listsentbyaddress", json::array());
|
||||||
transactionRpc.addResponse("z_viewtransaction", json{
|
transactionRpc.addResponse("z_viewtransaction", json{
|
||||||
{"spends", json::array({json{{"address", "zs-from"}}})},
|
{"spends", json::array({json{{"address", "zs-from"}}})},
|
||||||
{"outputs", json::array({
|
{"outputs", json::array({
|
||||||
@@ -1639,12 +1640,13 @@ void testNetworkRefreshRpcCollectors()
|
|||||||
auto transactionResult = Refresh::collectTransactionRefreshResult(transactionRpc, snapshot, 321, 4);
|
auto transactionResult = Refresh::collectTransactionRefreshResult(transactionRpc, snapshot, 321, 4);
|
||||||
EXPECT_TRUE(transactionRpc.methodNames() == std::vector<std::string>({
|
EXPECT_TRUE(transactionRpc.methodNames() == std::vector<std::string>({
|
||||||
"listtransactions", "z_listreceivedbyaddress", "z_listreceivedbyaddress",
|
"listtransactions", "z_listreceivedbyaddress", "z_listreceivedbyaddress",
|
||||||
"z_viewtransaction", "gettransaction"
|
"z_listsentbyaddress", "z_viewtransaction", "gettransaction"
|
||||||
}));
|
}));
|
||||||
EXPECT_EQ(transactionRpc.calls[1].params, json::array({"zs-one", 0}));
|
EXPECT_EQ(transactionRpc.calls[1].params, json::array({"zs-one", 0}));
|
||||||
EXPECT_EQ(transactionRpc.calls[2].params, json::array({"zs-two", 0}));
|
EXPECT_EQ(transactionRpc.calls[2].params, json::array({"zs-two", 0}));
|
||||||
EXPECT_EQ(transactionRpc.calls[3].params, json::array({"pending-send"}));
|
EXPECT_EQ(transactionRpc.calls[3].params, json::array({"zs-one", 0}));
|
||||||
EXPECT_EQ(transactionRpc.calls[4].params, json::array({"pending-send"}));
|
EXPECT_EQ(transactionRpc.calls[4].params, json::array({"pending-send"}));
|
||||||
|
EXPECT_EQ(transactionRpc.calls[5].params, json::array({"pending-send"}));
|
||||||
EXPECT_EQ(transactionResult.blockHeight, 321);
|
EXPECT_EQ(transactionResult.blockHeight, 321);
|
||||||
EXPECT_EQ(transactionResult.newViewTxEntries.size(), static_cast<size_t>(1));
|
EXPECT_EQ(transactionResult.newViewTxEntries.size(), static_cast<size_t>(1));
|
||||||
EXPECT_EQ(transactionResult.newViewTxEntries.count("pending-send"), static_cast<size_t>(1));
|
EXPECT_EQ(transactionResult.newViewTxEntries.count("pending-send"), static_cast<size_t>(1));
|
||||||
@@ -1657,6 +1659,129 @@ void testNetworkRefreshRpcCollectors()
|
|||||||
EXPECT_EQ(transactionResult.transactions.front().timestamp, static_cast<int64_t>(500));
|
EXPECT_EQ(transactionResult.transactions.front().timestamp, static_cast<int64_t>(500));
|
||||||
EXPECT_EQ(transactionResult.transactions[1].txid, std::string("transparent-a"));
|
EXPECT_EQ(transactionResult.transactions[1].txid, std::string("transparent-a"));
|
||||||
|
|
||||||
|
// Outgoing shielded (z->z) send discovery. A pure z->z send has valueBalance == 0, so it is
|
||||||
|
// absent from listtransactions AND (being a send, not a receive) from z_listreceivedbyaddress —
|
||||||
|
// it is surfaced only via the whole-wallet z_listsentbyaddress sweep, whose txids are folded
|
||||||
|
// into the z_viewtransaction enrichment to build the send row. Regression test for the
|
||||||
|
// "outgoing transactions missing from History" bug.
|
||||||
|
{
|
||||||
|
Refresh::TransactionRefreshSnapshot zzSnapshot;
|
||||||
|
zzSnapshot.shieldedAddresses = {"zs-mine"};
|
||||||
|
MockRefreshRpc zzRpc;
|
||||||
|
zzRpc.addResponse("listtransactions", json::array()); // no transparent movement
|
||||||
|
zzRpc.addResponse("z_listreceivedbyaddress", json::array()); // it's a send, not a receive
|
||||||
|
zzRpc.addResponse("z_listsentbyaddress", json::array({
|
||||||
|
json{{"txid", "zz-send"}} // discovered only here
|
||||||
|
}));
|
||||||
|
zzRpc.addResponse("z_viewtransaction", json{
|
||||||
|
{"spends", json::array({json{{"address", "zs-mine"}}})},
|
||||||
|
{"outputs", json::array({
|
||||||
|
json{{"outgoing", true}, {"address", "zs-recipient"}, {"value", 1.25},
|
||||||
|
{"memoStr", "z2z memo"}}
|
||||||
|
})}
|
||||||
|
});
|
||||||
|
zzRpc.addResponse("gettransaction", json{{"time", 800}, {"confirmations", 3}});
|
||||||
|
auto zzResult = Refresh::collectTransactionRefreshResult(zzRpc, zzSnapshot, 400, 4);
|
||||||
|
EXPECT_TRUE(zzRpc.methodNames() == std::vector<std::string>({
|
||||||
|
"listtransactions", "z_listreceivedbyaddress", "z_listsentbyaddress",
|
||||||
|
"z_viewtransaction", "gettransaction"
|
||||||
|
}));
|
||||||
|
EXPECT_EQ(zzResult.transactions.size(), static_cast<size_t>(1));
|
||||||
|
EXPECT_EQ(zzResult.transactions[0].txid, std::string("zz-send"));
|
||||||
|
EXPECT_EQ(zzResult.transactions[0].type, std::string("send"));
|
||||||
|
EXPECT_NEAR(zzResult.transactions[0].amount, -1.25, 0.00000001);
|
||||||
|
EXPECT_EQ(zzResult.transactions[0].address, std::string("zs-recipient"));
|
||||||
|
EXPECT_EQ(zzResult.transactions[0].memo, std::string("z2z memo"));
|
||||||
|
EXPECT_EQ(zzResult.transactions[0].timestamp, static_cast<int64_t>(800));
|
||||||
|
EXPECT_EQ(zzResult.newViewTxEntries.count("zz-send"), static_cast<size_t>(1));
|
||||||
|
|
||||||
|
// Guard: with no shielded addresses the sweep is skipped entirely (transparent sends are
|
||||||
|
// already covered by listtransactions), so no z_listsentbyaddress call is made.
|
||||||
|
Refresh::TransactionRefreshSnapshot noZSnapshot;
|
||||||
|
MockRefreshRpc noZRpc;
|
||||||
|
noZRpc.addResponse("listtransactions", json::array());
|
||||||
|
auto noZResult = Refresh::collectTransactionRefreshResult(noZRpc, noZSnapshot, 401, 4);
|
||||||
|
EXPECT_TRUE(noZRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
|
||||||
|
EXPECT_EQ(noZResult.transactions.size(), static_cast<size_t>(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// z->t deshield: the external transparent recipient is reported ONLY in z_listsentbyaddress's
|
||||||
|
// sends.transparentSends (z_viewtransaction sees only the shielded change note, isOutgoing=false;
|
||||||
|
// listtransactions omits the t-vout because inputs are shielded). The row must be synthesized
|
||||||
|
// directly from that payload — a direct sibling of the z->z gap.
|
||||||
|
{
|
||||||
|
Refresh::TransactionRefreshSnapshot ztSnapshot;
|
||||||
|
ztSnapshot.shieldedAddresses = {"zs-mine"};
|
||||||
|
MockRefreshRpc ztRpc;
|
||||||
|
ztRpc.addResponse("listtransactions", json::array()); // z-originated: no t-input row
|
||||||
|
ztRpc.addResponse("z_listreceivedbyaddress", json::array());
|
||||||
|
ztRpc.addResponse("z_listsentbyaddress", json::array({
|
||||||
|
json{{"txid", "zt-send"}, {"confirmations", 5}, {"blocktime", 900},
|
||||||
|
{"sends", json{
|
||||||
|
{"transparentSends", json::array({
|
||||||
|
json{{"address", "R-recipient"}, {"amount", -2.5}, {"vout", 0}}
|
||||||
|
})},
|
||||||
|
{"saplingSends", json::array()}
|
||||||
|
}}}
|
||||||
|
}));
|
||||||
|
// z->t viewtransaction: only a change note back to self -> no outgoing outputs.
|
||||||
|
ztRpc.addResponse("z_viewtransaction", json{{"spends", json::array()}, {"outputs", json::array()}});
|
||||||
|
auto ztResult = Refresh::collectTransactionRefreshResult(ztRpc, ztSnapshot, 500, 4);
|
||||||
|
EXPECT_EQ(ztResult.transactions.size(), static_cast<size_t>(1));
|
||||||
|
EXPECT_EQ(ztResult.transactions[0].txid, std::string("zt-send"));
|
||||||
|
EXPECT_EQ(ztResult.transactions[0].type, std::string("send"));
|
||||||
|
EXPECT_NEAR(ztResult.transactions[0].amount, -2.5, 0.00000001);
|
||||||
|
EXPECT_EQ(ztResult.transactions[0].address, std::string("R-recipient"));
|
||||||
|
EXPECT_EQ(ztResult.transactions[0].confirmations, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// t->t send is reported by BOTH listtransactions AND z_listsentbyaddress's transparentSends —
|
||||||
|
// it must appear ONCE (deduped by txid+address+amount), not doubled.
|
||||||
|
{
|
||||||
|
Refresh::TransactionRefreshSnapshot ttSnapshot;
|
||||||
|
ttSnapshot.shieldedAddresses = {"zs-mine"};
|
||||||
|
MockRefreshRpc ttRpc;
|
||||||
|
ttRpc.addResponse("listtransactions", json::array({
|
||||||
|
json{{"txid", "tt-send"}, {"category", "send"}, {"amount", -1.0},
|
||||||
|
{"time", 800}, {"confirmations", 3}, {"address", "R-dest"}}
|
||||||
|
}));
|
||||||
|
ttRpc.addResponse("z_listreceivedbyaddress", json::array());
|
||||||
|
ttRpc.addResponse("z_listsentbyaddress", json::array({
|
||||||
|
json{{"txid", "tt-send"}, {"confirmations", 3}, {"blocktime", 800},
|
||||||
|
{"sends", json{{"transparentSends", json::array({
|
||||||
|
json{{"address", "R-dest"}, {"amount", -1.0}, {"vout", 0}}
|
||||||
|
})}}}}
|
||||||
|
}));
|
||||||
|
ttRpc.addResponse("z_viewtransaction", json{{"spends", json::array()}, {"outputs", json::array()}});
|
||||||
|
auto ttResult = Refresh::collectTransactionRefreshResult(ttRpc, ttSnapshot, 500, 4);
|
||||||
|
int ttSends = 0;
|
||||||
|
for (const auto& t : ttResult.transactions)
|
||||||
|
if (t.txid == std::string("tt-send") && t.type == std::string("send")) ++ttSends;
|
||||||
|
EXPECT_EQ(ttSends, 1); // not double-counted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-output send: two outgoing outputs of EQUAL value to DIFFERENT recipients in one tx must
|
||||||
|
// both render (address-keyed dedup) — an amount-only dedup dropped the second.
|
||||||
|
{
|
||||||
|
Refresh::TransactionRefreshSnapshot multiSnapshot;
|
||||||
|
multiSnapshot.sendTxids = {"multi-send"};
|
||||||
|
MockRefreshRpc multiRpc;
|
||||||
|
multiRpc.addResponse("listtransactions", json::array());
|
||||||
|
multiRpc.addResponse("z_viewtransaction", json{
|
||||||
|
{"spends", json::array({json{{"address", "zs-from"}}})},
|
||||||
|
{"outputs", json::array({
|
||||||
|
json{{"outgoing", true}, {"address", "zs-a"}, {"value", 0.5}, {"memoStr", "a"}},
|
||||||
|
json{{"outgoing", true}, {"address", "zs-b"}, {"value", 0.5}, {"memoStr", "b"}}
|
||||||
|
})}
|
||||||
|
});
|
||||||
|
multiRpc.addResponse("gettransaction", json{{"time", 700}, {"confirmations", 2}});
|
||||||
|
auto multiResult = Refresh::collectTransactionRefreshResult(multiRpc, multiSnapshot, 500, 4);
|
||||||
|
int multiSends = 0;
|
||||||
|
for (const auto& t : multiResult.transactions)
|
||||||
|
if (t.txid == std::string("multi-send") && t.type == std::string("send")) ++multiSends;
|
||||||
|
EXPECT_EQ(multiSends, 2); // both equal-value outputs kept
|
||||||
|
}
|
||||||
|
|
||||||
Refresh::TransactionRefreshSnapshot cachedOnlySnapshot;
|
Refresh::TransactionRefreshSnapshot cachedOnlySnapshot;
|
||||||
auto cachedOnlyEntry = cachedEntry;
|
auto cachedOnlyEntry = cachedEntry;
|
||||||
cachedOnlyEntry.timestamp = 450;
|
cachedOnlyEntry.timestamp = 450;
|
||||||
@@ -1802,6 +1927,7 @@ void testNetworkRefreshRpcCollectors()
|
|||||||
strictRpc.addResponse("listtransactions", json::array());
|
strictRpc.addResponse("listtransactions", json::array());
|
||||||
strictRpc.addResponse("z_listreceivedbyaddress", json::array());
|
strictRpc.addResponse("z_listreceivedbyaddress", json::array());
|
||||||
strictRpc.addResponse("z_listreceivedbyaddress", json::array());
|
strictRpc.addResponse("z_listreceivedbyaddress", json::array());
|
||||||
|
strictRpc.addResponse("z_listsentbyaddress", json::array());
|
||||||
auto strictResult = Refresh::collectTransactionRefreshResult(strictRpc, strictSnapshot, 102, 4);
|
auto strictResult = Refresh::collectTransactionRefreshResult(strictRpc, strictSnapshot, 102, 4);
|
||||||
EXPECT_EQ(strictResult.shieldedAddressesScanned, static_cast<size_t>(2)); // both re-scanned
|
EXPECT_EQ(strictResult.shieldedAddressesScanned, static_cast<size_t>(2)); // both re-scanned
|
||||||
|
|
||||||
@@ -1813,10 +1939,12 @@ void testNetworkRefreshRpcCollectors()
|
|||||||
MockRefreshRpc tolerantRpc;
|
MockRefreshRpc tolerantRpc;
|
||||||
tolerantRpc.addResponse("listtransactions", json::array());
|
tolerantRpc.addResponse("listtransactions", json::array());
|
||||||
// No z_listreceivedbyaddress responses: the addresses must be skipped (else call() throws).
|
// No z_listreceivedbyaddress responses: the addresses must be skipped (else call() throws).
|
||||||
|
// The shielded scan still completes, so the whole-wallet z_listsentbyaddress sweep runs once.
|
||||||
|
tolerantRpc.addResponse("z_listsentbyaddress", json::array());
|
||||||
auto tolerantResult = Refresh::collectTransactionRefreshResult(tolerantRpc, tolerantSnapshot, 102, 4);
|
auto tolerantResult = Refresh::collectTransactionRefreshResult(tolerantRpc, tolerantSnapshot, 102, 4);
|
||||||
EXPECT_EQ(tolerantResult.shieldedAddressesScanned, static_cast<size_t>(0)); // both skipped
|
EXPECT_EQ(tolerantResult.shieldedAddressesScanned, static_cast<size_t>(0)); // both skipped
|
||||||
EXPECT_TRUE(tolerantResult.shieldedScanComplete);
|
EXPECT_TRUE(tolerantResult.shieldedScanComplete);
|
||||||
EXPECT_TRUE(tolerantRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
|
EXPECT_TRUE(tolerantRpc.methodNames() == std::vector<std::string>({"listtransactions", "z_listsentbyaddress"}));
|
||||||
}
|
}
|
||||||
|
|
||||||
Refresh::TransactionRefreshSnapshot recentSnapshot;
|
Refresh::TransactionRefreshSnapshot recentSnapshot;
|
||||||
@@ -1902,10 +2030,11 @@ void testNetworkRefreshRpcCollectors()
|
|||||||
MockRefreshRpc finalShieldedRpc;
|
MockRefreshRpc finalShieldedRpc;
|
||||||
finalShieldedRpc.addResponse("listtransactions", json::array());
|
finalShieldedRpc.addResponse("listtransactions", json::array());
|
||||||
finalShieldedRpc.addResponse("z_listreceivedbyaddress", json::array());
|
finalShieldedRpc.addResponse("z_listreceivedbyaddress", json::array());
|
||||||
|
finalShieldedRpc.addResponse("z_listsentbyaddress", json::array());
|
||||||
auto finalShielded = Refresh::collectTransactionRefreshResult(
|
auto finalShielded = Refresh::collectTransactionRefreshResult(
|
||||||
finalShieldedRpc, partialShieldedSnapshot, 400, 0);
|
finalShieldedRpc, partialShieldedSnapshot, 400, 0);
|
||||||
EXPECT_TRUE(finalShieldedRpc.methodNames() == std::vector<std::string>({
|
EXPECT_TRUE(finalShieldedRpc.methodNames() == std::vector<std::string>({
|
||||||
"listtransactions", "z_listreceivedbyaddress"
|
"listtransactions", "z_listreceivedbyaddress", "z_listsentbyaddress"
|
||||||
}));
|
}));
|
||||||
EXPECT_EQ(finalShieldedRpc.calls[1].params, json::array({"zs-two", 0}));
|
EXPECT_EQ(finalShieldedRpc.calls[1].params, json::array({"zs-two", 0}));
|
||||||
EXPECT_TRUE(finalShielded.shieldedScanComplete);
|
EXPECT_TRUE(finalShielded.shieldedScanComplete);
|
||||||
@@ -1922,9 +2051,10 @@ void testNetworkRefreshRpcCollectors()
|
|||||||
});
|
});
|
||||||
MockRefreshRpc cachedShieldedRpc;
|
MockRefreshRpc cachedShieldedRpc;
|
||||||
cachedShieldedRpc.addResponse("listtransactions", json::array());
|
cachedShieldedRpc.addResponse("listtransactions", json::array());
|
||||||
|
cachedShieldedRpc.addResponse("z_listsentbyaddress", json::array());
|
||||||
auto cachedShielded = Refresh::collectTransactionRefreshResult(
|
auto cachedShielded = Refresh::collectTransactionRefreshResult(
|
||||||
cachedShieldedRpc, cachedShieldedSnapshot, 500, 0);
|
cachedShieldedRpc, cachedShieldedSnapshot, 500, 0);
|
||||||
EXPECT_TRUE(cachedShieldedRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
|
EXPECT_TRUE(cachedShieldedRpc.methodNames() == std::vector<std::string>({"listtransactions", "z_listsentbyaddress"}));
|
||||||
EXPECT_TRUE(cachedShielded.shieldedScanComplete);
|
EXPECT_TRUE(cachedShielded.shieldedScanComplete);
|
||||||
EXPECT_EQ(cachedShielded.shieldedAddressesScanned, static_cast<size_t>(0));
|
EXPECT_EQ(cachedShielded.shieldedAddressesScanned, static_cast<size_t>(0));
|
||||||
EXPECT_EQ(cachedShielded.transactions.size(), static_cast<size_t>(1));
|
EXPECT_EQ(cachedShielded.transactions.size(), static_cast<size_t>(1));
|
||||||
@@ -1970,6 +2100,7 @@ void testNetworkRefreshRpcCollectors()
|
|||||||
json{{"txid", "shielded-mined"}, {"amount", 4.0}, {"confirmations", 102},
|
json{{"txid", "shielded-mined"}, {"amount", 4.0}, {"confirmations", 102},
|
||||||
{"time", 210}, {"memoStr", "pool"}}
|
{"time", 210}, {"memoStr", "pool"}}
|
||||||
}));
|
}));
|
||||||
|
miningTxRpc.addResponse("z_listsentbyaddress", json::array());
|
||||||
auto miningTxResult = Refresh::collectTransactionRefreshResult(miningTxRpc, miningSnapshot, 328, 0);
|
auto miningTxResult = Refresh::collectTransactionRefreshResult(miningTxRpc, miningSnapshot, 328, 0);
|
||||||
EXPECT_EQ(miningTxResult.transactions.size(), static_cast<size_t>(2));
|
EXPECT_EQ(miningTxResult.transactions.size(), static_cast<size_t>(2));
|
||||||
EXPECT_EQ(miningTxResult.transactions[0].type, std::string("mined"));
|
EXPECT_EQ(miningTxResult.transactions[0].type, std::string("mined"));
|
||||||
@@ -2005,6 +2136,25 @@ void testNetworkRefreshResultModels()
|
|||||||
EXPECT_EQ(state.longestchain, 110);
|
EXPECT_EQ(state.longestchain, 110);
|
||||||
EXPECT_EQ(state.notarized, 90);
|
EXPECT_EQ(state.notarized, 90);
|
||||||
EXPECT_EQ(state.last_balance_update, static_cast<int64_t>(1234));
|
EXPECT_EQ(state.last_balance_update, static_cast<int64_t>(1234));
|
||||||
|
EXPECT_TRUE(state.sync.tip_known); // longestchain>0 -> peer-derived tip is known
|
||||||
|
EXPECT_FALSE(state.sync.isSynced()); // still catching up (blocks 100 < tip 110)
|
||||||
|
|
||||||
|
// Peerless node: the daemon reports longestchain == 0. Even at the local best height (blocks ==
|
||||||
|
// headers) it must NOT be reported synced (the tip is unknown) — otherwise Send would be offered
|
||||||
|
// against a stale balance on a node that has silently lost all peers. (audit: peerless-synced)
|
||||||
|
{
|
||||||
|
dragonx::WalletState peerless;
|
||||||
|
auto pc = Refresh::parseCoreRefreshResult(
|
||||||
|
json{{"private", "5.00000000"}, {"transparent", "0.00000000"}, {"total", "5.00000000"}},
|
||||||
|
json{{"private", "5.00000000"}, {"transparent", "0.00000000"}, {"total", "5.00000000"}},
|
||||||
|
true,
|
||||||
|
json{{"blocks", 200}, {"headers", 200}, {"bestblockhash", "peerless-200"},
|
||||||
|
{"verificationprogress", 1.0}, {"longestchain", 0}, {"notarized", 0}},
|
||||||
|
true);
|
||||||
|
Refresh::applyCoreRefreshResult(peerless, pc, 1235);
|
||||||
|
EXPECT_FALSE(peerless.sync.tip_known);
|
||||||
|
EXPECT_FALSE(peerless.sync.isSynced()); // peerless -> not synced despite blocks == headers
|
||||||
|
}
|
||||||
|
|
||||||
auto connectionInfo = Refresh::parseConnectionInfoResult(
|
auto connectionInfo = Refresh::parseConnectionInfoResult(
|
||||||
json{{"version", 120000}, {"protocolversion", 170002}, {"p2pport", 8233},
|
json{{"version", 120000}, {"protocolversion", 170002}, {"p2pport", 8233},
|
||||||
@@ -2638,6 +2788,28 @@ void testNodeStatusBanner()
|
|||||||
NodeBannerInputs in; in.connected = true;
|
NodeBannerInputs in; in.connected = true;
|
||||||
EXPECT_TRUE(!evaluateNodeStatusBanner(in).show);
|
EXPECT_TRUE(!evaluateNodeStatusBanner(in).show);
|
||||||
}
|
}
|
||||||
|
// Connected but no peer-derived tip (0 peers) once chain info is in → amber "no peers" banner.
|
||||||
|
{
|
||||||
|
NodeBannerInputs in;
|
||||||
|
in.connected = true; in.tip_known = false; in.blocks = 3262059;
|
||||||
|
in.connection_status = "Peers: 0";
|
||||||
|
NodeBannerState s = evaluateNodeStatusBanner(in);
|
||||||
|
EXPECT_TRUE(s.show);
|
||||||
|
EXPECT_TRUE(s.severity == NodeBannerSeverity::Warning);
|
||||||
|
EXPECT_TRUE(s.reason == NodeBannerReason::NoPeers);
|
||||||
|
EXPECT_TRUE(s.action == NodeBannerAction::None);
|
||||||
|
EXPECT_EQ(s.detail, std::string("Peers: 0"));
|
||||||
|
}
|
||||||
|
// Connected, tip unknown, but no chain info yet (blocks==0) → no banner (avoid startup flicker).
|
||||||
|
{
|
||||||
|
NodeBannerInputs in; in.connected = true; in.tip_known = false; in.blocks = 0;
|
||||||
|
EXPECT_TRUE(!evaluateNodeStatusBanner(in).show);
|
||||||
|
}
|
||||||
|
// Connected with a known tip → no banner.
|
||||||
|
{
|
||||||
|
NodeBannerInputs in; in.connected = true; in.tip_known = true; in.blocks = 100;
|
||||||
|
EXPECT_TRUE(!evaluateNodeStatusBanner(in).show);
|
||||||
|
}
|
||||||
// Expected startup phases own the screen (loading/warmup overlay) → no banner.
|
// Expected startup phases own the screen (loading/warmup overlay) → no banner.
|
||||||
{
|
{
|
||||||
NodeBannerInputs in; in.warming_up = true;
|
NodeBannerInputs in; in.warming_up = true;
|
||||||
@@ -2792,6 +2964,12 @@ void testSeedMigrationResume()
|
|||||||
// No txid and no opid → the Sweep gate (whether or not connected).
|
// No txid and no opid → the Sweep gate (whether or not connected).
|
||||||
EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "", true) == MigrationResume::SweepGate);
|
EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "", true) == MigrationResume::SweepGate);
|
||||||
EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "", false) == MigrationResume::SweepGate);
|
EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "", false) == MigrationResume::SweepGate);
|
||||||
|
|
||||||
|
// fund-safety: the Sweep step may only short-circuit a ~0 balance straight to adopt when NO sweep
|
||||||
|
// was ever submitted (a genuinely empty wallet). Once a sweep has been broadcast, a ~0 balance is
|
||||||
|
// ambiguous (the tx may be unconfirmed in the mempool), so direct adopt must be blocked.
|
||||||
|
EXPECT_TRUE(dragonx::sweepGateMayAdoptEmptyWallet(/*sweepSubmitted=*/false)); // empty wallet -> ok
|
||||||
|
EXPECT_FALSE(dragonx::sweepGateMayAdoptEmptyWallet(/*sweepSubmitted=*/true)); // in-flight -> blocked
|
||||||
}
|
}
|
||||||
|
|
||||||
void testLoggerFileSink()
|
void testLoggerFileSink()
|
||||||
@@ -5301,6 +5479,92 @@ void testLiteWalletControllerAsyncLifecycleFailover()
|
|||||||
dragonx::test::g_liteFakeWarmupServerSubstr.clear();
|
dragonx::test::g_liteFakeWarmupServerSubstr.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// C1/M1: the ASYNC lifecycle path (the one the Settings UI actually calls) must apply the
|
||||||
|
// passphrase — encrypt a freshly created/restored wallet, unlock an existing encrypted one.
|
||||||
|
// Previously the async path discarded the passphrase (encrypt/unlock lived only in the sync
|
||||||
|
// wrappers), so a UI-created wallet stored its seed in PLAINTEXT and an encrypted wallet opened
|
||||||
|
// but stayed locked.
|
||||||
|
void testLiteWalletControllerAsyncAppliesPassphrase()
|
||||||
|
{
|
||||||
|
using namespace dragonx::wallet;
|
||||||
|
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
|
||||||
|
LiteConnectionSettings conn;
|
||||||
|
conn.chainName = "main";
|
||||||
|
conn.servers = { LiteServerEndpoint{"https://good.example", "Good", true} };
|
||||||
|
conn.selectionMode = LiteServerSelectionMode::Sticky;
|
||||||
|
conn.stickyServerUrl = "https://good.example";
|
||||||
|
|
||||||
|
const auto drain = [](LiteWalletController& c) {
|
||||||
|
for (int i = 0; i < 400 && c.lifecycleRequestInProgress(); ++i)
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||||
|
c.pumpLifecycleResult();
|
||||||
|
};
|
||||||
|
|
||||||
|
// C1: async create with a passphrase encrypts (and locks) the new wallet; same passphrase unlocks.
|
||||||
|
{
|
||||||
|
dragonx::test::resetLiteFakeCounters();
|
||||||
|
dragonx::test::g_liteFakeWalletExists = false;
|
||||||
|
dragonx::test::g_liteFakeEncrypted = false;
|
||||||
|
dragonx::test::g_liteFakeLocked = false;
|
||||||
|
LiteWalletController controller(liteCaps, conn,
|
||||||
|
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||||||
|
LiteWalletCreateRequest req;
|
||||||
|
req.passphrase = "hunter2";
|
||||||
|
EXPECT_TRUE(controller.beginCreateWalletAsync(req));
|
||||||
|
drain(controller);
|
||||||
|
EXPECT_TRUE(controller.walletOpen());
|
||||||
|
const auto s = controller.encryptionStatus();
|
||||||
|
EXPECT_TRUE(s.encrypted); // async create ENCRYPTED the wallet (C1 fixed)
|
||||||
|
EXPECT_TRUE(s.locked); // encrypt locks immediately
|
||||||
|
EXPECT_TRUE(controller.unlockWallet("hunter2"));
|
||||||
|
EXPECT_FALSE(controller.encryptionStatus().locked);
|
||||||
|
}
|
||||||
|
|
||||||
|
// C1: async restore with a passphrase likewise encrypts the restored wallet.
|
||||||
|
{
|
||||||
|
dragonx::test::resetLiteFakeCounters();
|
||||||
|
dragonx::test::g_liteFakeWalletExists = false;
|
||||||
|
dragonx::test::g_liteFakeEncrypted = false;
|
||||||
|
dragonx::test::g_liteFakeLocked = false;
|
||||||
|
LiteWalletController controller(liteCaps, conn,
|
||||||
|
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||||||
|
LiteWalletRestoreRequest req;
|
||||||
|
req.seedPhrase = "abandon abandon abandon abandon abandon abandon abandon abandon "
|
||||||
|
"abandon abandon abandon abandon abandon abandon abandon abandon "
|
||||||
|
"abandon abandon abandon abandon abandon abandon abandon art";
|
||||||
|
req.birthday = 0;
|
||||||
|
req.passphrase = "hunter2";
|
||||||
|
EXPECT_TRUE(controller.beginRestoreWalletAsync(req));
|
||||||
|
drain(controller);
|
||||||
|
EXPECT_TRUE(controller.walletOpen());
|
||||||
|
EXPECT_TRUE(controller.encryptionStatus().encrypted); // C1 fixed for restore
|
||||||
|
}
|
||||||
|
|
||||||
|
// M1: async open of an already-encrypted+locked wallet unlocks it with the supplied passphrase.
|
||||||
|
{
|
||||||
|
dragonx::test::resetLiteFakeCounters();
|
||||||
|
dragonx::test::g_liteFakeWalletExists = true;
|
||||||
|
dragonx::test::g_liteFakeEncrypted = true; // existing wallet is encrypted...
|
||||||
|
dragonx::test::g_liteFakeLocked = true; // ...and locked at open time
|
||||||
|
LiteWalletController controller(liteCaps, conn,
|
||||||
|
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||||||
|
LiteWalletOpenRequest req;
|
||||||
|
req.passphrase = "hunter2";
|
||||||
|
EXPECT_TRUE(controller.beginOpenWalletAsync(req));
|
||||||
|
drain(controller);
|
||||||
|
EXPECT_TRUE(controller.walletOpen());
|
||||||
|
const auto s = controller.encryptionStatus();
|
||||||
|
EXPECT_TRUE(s.encrypted);
|
||||||
|
EXPECT_FALSE(s.locked); // async open UNLOCKED it (M1 fixed)
|
||||||
|
}
|
||||||
|
|
||||||
|
dragonx::test::g_liteFakeWalletExists = false;
|
||||||
|
dragonx::test::g_liteFakeEncrypted = false;
|
||||||
|
dragonx::test::g_liteFakeLocked = false;
|
||||||
|
dragonx::test::g_liteFakeDeadServerSubstr.clear();
|
||||||
|
dragonx::test::g_liteFakeWarmupServerSubstr.clear();
|
||||||
|
}
|
||||||
|
|
||||||
// M2: a parsed lite refresh bundle maps through to the app's WalletState (the last hop
|
// M2: a parsed lite refresh bundle maps through to the app's WalletState (the last hop
|
||||||
// the Balance/Receive/Transactions tabs read), with zatoshi->DRGX conversion, z/t address
|
// the Balance/Receive/Transactions tabs read), with zatoshi->DRGX conversion, z/t address
|
||||||
// split, transaction typing, confirmations, and sync progress.
|
// split, transaction typing, confirmations, and sync progress.
|
||||||
@@ -7500,6 +7764,7 @@ int main()
|
|||||||
testLiteWalletControllerM5Persistence();
|
testLiteWalletControllerM5Persistence();
|
||||||
testLiteWalletControllerEncryption();
|
testLiteWalletControllerEncryption();
|
||||||
testLiteWalletControllerCreateEncryptsWithPassphrase();
|
testLiteWalletControllerCreateEncryptsWithPassphrase();
|
||||||
|
testLiteWalletControllerAsyncAppliesPassphrase();
|
||||||
testLiteChainNameMigration();
|
testLiteChainNameMigration();
|
||||||
testLiteRefreshModelAppliesToWalletState();
|
testLiteRefreshModelAppliesToWalletState();
|
||||||
testLiteSendShowsRecipientFromOutgoing();
|
testLiteSendShowsRecipientFromOutgoing();
|
||||||
|
|||||||
Reference in New Issue
Block a user