perf(migrate): gate the seed-migration confirming balance scan

While the seed migration sits on the Confirming gate it polled the
sweep's confirmations and a full wallet balance together on every pass.
That balance call is the expensive one, and it only matters once the
sweep is mined and we do not yet have a post-mine figure, yet it ran
every poll even mid-sync when it cannot move the gate.

- The confirmation count is still read on every poll.
- The balance scan now runs only when it can actually advance the adopt
  gate: the sweep is mined, no post-mine balance has been recorded yet,
  and no sync is in progress. The manual Refresh button forces it
  regardless.
- The decision lives in a small pure helper, and the poller snapshots
  the main-thread state it needs rather than reading members off the
  worker thread.
- The Confirming screen now claims a leftover remainder (and offers the
  re-sweep button) only once the balance has actually been measured; while
  it is still unknown it keeps waiting instead of prompting on a sentinel.

Tests cover the gate helper across the forced, syncing, and mined cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 19:24:12 -05:00
co-authored by Claude Opus 4.8
parent f1b0ae5ce7
commit e1003cba43
5 changed files with 54 additions and 11 deletions
+5 -2
View File
@@ -5015,7 +5015,10 @@ void App::renderSeedMigrationDialog()
if (confirmed && legacyEmpty) {
if (ui::material::TactileButton(TR("mig_adopt"), ImVec2(180 * dp, 0)))
beginAdoptSeedWallet();
} else if (confirmed && !legacyEmpty) {
} else if (confirmed && seed_migration_legacy_remaining_ >= 0.0 && !legacyEmpty) {
// Only claim a remainder once the legacy balance was actually measured; while a sweep is
// mined but the balance scan is still gated (node syncing), remaining stays at the -1.0
// sentinel, so fall through to the waiting state rather than offering a sweep on an unknown.
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
ImGui::TextWrapped("%s", TR("mig_remainder"));
ImGui::PopStyleColor();
@@ -5026,7 +5029,7 @@ void App::renderSeedMigrationDialog()
}
ImGui::SameLine();
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
pollSweepStatus();
pollSweepStatus(/*forceBalance=*/true);
ImGui::SameLine();
if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0)))
close(); // pending state + txid persist; resumes here next time
+1 -1
View File
@@ -1155,7 +1155,7 @@ private:
// opid the daemon no longer knows (stale) -> back to the dismissable Sweep gate (re-check balance).
std::function<void(bool, const std::string&)> makeSweepCompletionCallback(bool resumed);
void beginSweepToSeedWallet(); // z_mergetoaddress ["ANY_TADDR","ANY_ZADDR"] -> dest
void pollSweepStatus(); // Confirming step: poll sweep confirmations + legacy balance
void pollSweepStatus(bool forceBalance = false); // Confirming step: confs always; balance only when it can move the gate
void locateSweepTxid(); // recover a lost sweep txid (send to dest) -> Confirming gate
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
void provisionChatIdentityFromSecret(std::string secret);
+15 -8
View File
@@ -33,6 +33,7 @@
#include "rpc/rpc_client.h"
#include "rpc/rpc_worker.h"
#include "rpc/connection.h"
#include "services/seed_migration_poll.h" // migrationWantsBalanceScan: gate the Confirming balance scan
#include "services/lowest_block_search.h" // detectLowestAvailableBlockHeight: pure search + probe classification
#include "chat/chat_identity.h" // deriveChatIdentityFromSecret for HushChat identity provisioning
#include "ui/windows/chat_tab.h" // ui::ResetChatTab — wipe chat UI plaintext on a wallet switch
@@ -6338,30 +6339,36 @@ std::function<void(bool, const std::string&)> App::makeSweepCompletionCallback(b
// adopt step is gated on the tx being mined (confs >= 1) AND the legacy balance being ~0, so we
// never swap wallet.dat while the funds could still bounce back (dropped/reorged tx) or while a
// remainder is left behind (a sweep too big for one transaction).
void App::pollSweepStatus()
void App::pollSweepStatus(bool forceBalance)
{
if (capture_mode_) return; // no live ops during a UI sweep
if (!rpc_ || !worker_ || !state_.connected) return;
if (seed_migration_confirm_in_flight_ || seed_migration_sweep_txid_.empty()) return;
seed_migration_confirm_in_flight_ = true;
const std::string txid = seed_migration_sweep_txid_;
worker_->post([this, txid]() -> rpc::RPCWorker::MainCb {
int confs = 0; double remaining = -1.0; bool ok = false;
// Snapshot main-thread state for the worker - never read members off the worker thread.
const bool syncing = effectivelySyncing();
const double priorRemaining = seed_migration_legacy_remaining_;
worker_->post([this, txid, forceBalance, syncing, priorRemaining]() -> rpc::RPCWorker::MainCb {
int confs = 0; double remaining = -1.0; bool ok = false; bool scanned = false;
rpc::RPCClient::TraceScope trace("Migrate / confirm");
try {
auto tx = rpc_->call("gettransaction", {txid});
if (tx.contains("confirmations") && tx["confirmations"].is_number())
confs = tx["confirmations"].get<int>();
auto bal = rpc_->call("z_gettotalbalance");
if (bal.contains("total") && bal["total"].is_string())
remaining = std::stod(bal["total"].get<std::string>());
if (services::migrationWantsBalanceScan(forceBalance, syncing, confs, priorRemaining)) {
auto bal = rpc_->call("z_gettotalbalance");
if (bal.contains("total") && bal["total"].is_string())
remaining = std::stod(bal["total"].get<std::string>());
scanned = true;
}
ok = true;
} catch (...) {}
return [this, confs, remaining, ok]() {
return [this, confs, remaining, ok, scanned]() {
seed_migration_confirm_in_flight_ = false;
if (ok) {
seed_migration_sweep_confs_ = confs;
seed_migration_legacy_remaining_ = remaining;
if (scanned) seed_migration_legacy_remaining_ = remaining;
}
};
});
+20
View File
@@ -0,0 +1,20 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
namespace dragonx {
namespace services {
// The expensive z_gettotalbalance only changes the seed-migration adopt gate once the sweep is mined and
// we have no post-mine figure yet (priorRemaining < 0). A manual Refresh forces it; an active sync
// suppresses the automatic scan (the Refresh button still overrides).
inline bool migrationWantsBalanceScan(bool forceBalance, bool syncing, int confs, double priorRemaining)
{
if (forceBalance) return true;
return !syncing && confs >= 1 && priorRemaining < 0.0;
}
} // namespace services
} // namespace dragonx
+13
View File
@@ -44,6 +44,7 @@
#include "services/wallet_scan_governor.h"
#include "services/scan_policy.h"
#include "services/connect_scan_gate.h"
#include "services/seed_migration_poll.h"
#include "services/chat_scan_policy.h"
#include "services/mining_thread_commit.h"
#include "services/network_refresh_service.h"
@@ -18292,6 +18293,17 @@ void testLiteWalletExistsCache()
dragonx::test::g_liteFakeWalletExists = true; // restore default for later tests
}
void testMigrationWantsBalanceScan()
{
using dragonx::services::migrationWantsBalanceScan;
EXPECT_FALSE(migrationWantsBalanceScan(false, false, 0, -1.0)); // not mined
EXPECT_TRUE(migrationWantsBalanceScan(false, false, 1, -1.0)); // mined, no figure yet
EXPECT_FALSE(migrationWantsBalanceScan(false, false, 3, 0.0)); // already scanned
EXPECT_FALSE(migrationWantsBalanceScan(false, false, 3, 2.5)); // already scanned (remainder)
EXPECT_TRUE(migrationWantsBalanceScan(true, true, 0, 0.0)); // forced overrides sync / unmined
EXPECT_FALSE(migrationWantsBalanceScan(false, true, 1, -1.0)); // syncing defers the automatic scan
}
int main()
{
testConnectionConfig();
@@ -18348,6 +18360,7 @@ int main()
testRefreshProfileSelection();
testConnectionInfoSeeding();
testConnectScanGate();
testMigrationWantsBalanceScan();
testScanPolicy();
testChatScanPolicy();
testChooseChatPayer();