feat(migrate): persist the sweep opid so a mid-sweep interruption can resume (W3-3)

Migrate-to-seed submits z_mergetoaddress -> an async opid, then only persists the resolved
txid once the op completes. An app-close during Sweeping (opid submitted, txid not yet
resolved) dropped the opid and resumed at the re-sweep gate, silently losing the tx.

Now the opid is persisted and re-tracked on resume. If the daemon forgot it (restart —
its op queue is in-memory only), the existing poller flags it stale and the callback falls
back to the dismissable Sweep gate; it can never hang (a thrown RPC aborts the poll, so a
stale classification only comes from a *successful* poll that omits the opid).

- New seed_migration_sweep_opid setting; adopted atomically with clearing any prior txid in
  the SAME settings.save(), and only once the submit succeeds — so a failed "Sweep remaining"
  re-sweep keeps the already-mined first sweep's Confirming context, and txid/opid are never
  both authoritative (resume checks txid first; torn-write safe).
- Resume routing extracted to a pure, unit-tested helper
  (data/seed_migration_resume.h::decideSeedMigrationResume): txid -> Confirming; opid AND
  connected -> re-track (Sweeping); else -> the dismissable Sweep gate. The connectivity gate
  keeps a disconnected resume out of the buttonless Sweeping spinner.
- Shared makeSweepCompletionCallback(resumed): success -> Confirming; resumed-stale -> Sweep
  gate (re-fetch balance + "may have already completed" copy); fresh-fail -> Error.

Fund safety unchanged: adopt still gated on legacy balance ~0 AND sweep tx mined; legacy
wallet.dat only ever moved to a never-deleted timestamped .bak.

Reviewed in two adversarial rounds (design + implementation) per the migration-code mandate;
both safety facts (no fund loss, no hang) held, and the resume-UX traps they surfaced are
fixed. Build-clean; ctest 1/1 (adds testSeedMigrationResume). See docs/wallet-hardening.md.

*** Still requires a live mainnet interrupted-sweep run before release (human gate). ***

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 22:11:37 -05:00
parent 8bb3198562
commit 32be868dbc
7 changed files with 195 additions and 34 deletions

View File

@@ -18,7 +18,7 @@ Status legend: ☐ not started · ◐ in progress · ☑ landed & verified
|-------|----------|-------|--------| |-------|----------|-------|--------|
| **P0-A** | W7-1, W2-1, W4-1, W4-3, W2-3, W4-5, W5-3 ✓ | Secret hardening (console redaction + delete-export + memzero + lite encrypt-at-create) | ☑ 7/7 | | **P0-A** | W7-1, W2-1, W4-1, W4-3, W2-3, W4-5, W5-3 ✓ | Secret hardening (console redaction + delete-export + memzero + lite encrypt-at-create) | ☑ 7/7 |
| **P0-B** | W2-2/W4-2, W2-4 | Encryption integrity (never silently unencrypted) | ☑ | | **P0-B** | W2-2/W4-2, W2-4 | Encryption integrity (never silently unencrypted) | ☑ |
| **P1-A** | W3-1, W3-2, W3-4 ✓ · W3-3 | Migrate-to-seed correctness (fund-adjacent) | ◐ 3/4 | | **P1-A** | W3-1, W3-2, W3-4, W3-3 | Migrate-to-seed correctness (fund-adjacent) | ☑ 4/4 (W3-3 pending a live-mainnet run) |
| **P1-B** | W1-1, W1-2, W1-3, W1-4 ✓ + startup guard | Missing/wrong wallet-file safety | ☑ | | **P1-B** | W1-1, W1-2, W1-3, W1-4 ✓ + startup guard | Missing/wrong wallet-file safety | ☑ |
| **P2** | W5-1, W5-2, W6-1, W6-3, W6-2 ✓ | Stale state & lite save-failure surfacing | ☑ 5/5 | | **P2** | W5-1, W5-2, W6-1, W6-3, W6-2 ✓ | Stale state & lite save-failure surfacing | ☑ 5/5 |
| **F** | W7-2, W7-3, W7-4 ✓ · QoL: copy-diag + open-log + node-error-banner + staleness-badge + alert-history ✓ | Diagnostics foundation + QoL bundle | ☑ | | **F** | W7-2, W7-3, W7-4 ✓ · QoL: copy-diag + open-log + node-error-banner + staleness-badge + alert-history ✓ | Diagnostics foundation + QoL bundle | ☑ |
@@ -136,7 +136,11 @@ Land W7-2 first — it unblocks the rest.
- **W1-3 (Med):** `syncedHere` was stamped in the `markOpened` block at bare connect (idHash still empty), letting a freshly-restored wallet skip its needed rescan. It's now stamped only once the identity is verified (idHash non-empty), so it takes effect at the post-address-refresh index update (`updateWalletIndexForActiveWallet` after addresses load), while `lastOpenedEpoch` still records at open. - **W1-3 (Med):** `syncedHere` was stamped in the `markOpened` block at bare connect (idHash still empty), letting a freshly-restored wallet skip its needed rescan. It's now stamped only once the identity is verified (idHash non-empty), so it takes effect at the post-address-refresh index update (`updateWalletIndexForActiveWallet` after addresses load), while `lastOpenedEpoch` still records at open.
- **Startup guard (the W1-1 launch counterpart):** `App::init` now `exists()`-checks the recorded active wallet before the daemon is configured; a **non-default** active wallet that was moved/deleted between sessions falls back to the default `wallet.dat` with a warning, instead of the daemon silently auto-creating an empty wallet under the missing name. Runs before the PIN-vault init so the vault is scoped to the wallet actually opened. - **Startup guard (the W1-1 launch counterpart):** `App::init` now `exists()`-checks the recorded active wallet before the daemon is configured; a **non-default** active wallet that was moved/deleted between sessions falls back to the default `wallet.dat` with a warning, instead of the daemon silently auto-creating an empty wallet under the missing name. Runs before the PIN-vault init so the vault is scoped to the wallet actually opened.
Build-clean; `ctest` 1/1. Build-clean; `ctest` 1/1.
- **P1-A / W3-3 (sweep opid persistence)** — **deferred for careful, adversarially-reviewed work** (not rushed). `trackOperation` only enqueues the opid for a background poller; a resume that re-tracks a persisted opid is only safe if the poller times out a *stale* opid (daemon restarted → op gone) rather than polling forever — otherwise a resume would hang the migration permanently, worse than today's re-sweep. Verifying that (and the double-sweep interactions) is exactly the "two rounds of adversarial review + a live mainnet run" the migration code mandates. The existing safety gates (adopt requires the legacy balance ~0 AND the sweep tx mined) already prevent fund *loss* on a mid-sweep interruption; W3-3 is a stuck-state robustness improvement, so it can wait for a dedicated pass. - **P1-A / W3-3 (sweep opid persistence)** — **implemented + two rounds of adversarial review** (the "live mainnet run" the migration code mandates is the remaining gate — see below). The deferral's core fear (re-tracking a stale opid hangs forever) was **refuted by the code**: the opid poller (`app.cpp:1122`) + `parseOperationStatusPoll` classify a tracked opid absent from a *successful* `z_getoperationstatus` as stale, remove it, and fire the callback `ok=false` — a thrown RPC aborts the poll so there's never a *false* stale. So re-tracking yields at worst one clean failure, never a hang.
- **What landed:** a persisted `seed_migration_sweep_opid` setting; the opid is adopted **atomically** with clearing any prior txid in the *same* `settings.save()` **only once the submit succeeds** (torn-write safe; txid always outranks opid on resume). Resume routing is a pure, unit-tested helper (`data/seed_migration_resume.h::decideSeedMigrationResume`): txid → Confirming; opid **and connected** → re-track (`Sweeping`); otherwise → the dismissable Sweep gate. The shared `makeSweepCompletionCallback(resumed)`: success → Confirming; resumed-stale → Sweep gate (re-fetch balance, honest "may have already completed" copy); fresh-fail → Error.
- **Round 1 (design review, 4 skeptics)** confirmed both safety facts (no fund loss — adopt gate + never-deleted `.bak` untouched; no hang) and caught 3 real resume-UX traps, all fixed: a missing **connectivity gate** (would trap the user in the buttonless `Sweeping` spinner while offline), a **missing balance re-fetch** on the stale fallback (permanent "Checking balance…"), and honest messaging since a daemon restart makes even a *successful* sweep read "stale".
- **Round 2 (implementation review, 3 reviewers)** caught one regression — clearing the old txid at sweep *entry* would forget an already-mined first sweep if a remainder re-sweep's submit failed; fixed by the atomic-on-success swap above. All other fixes verified present + correct.
- **⚑ Remaining gate — live mainnet run (user):** per CLAUDE.md this fund-moving path must be exercised once on mainnet before it ships. The self-verifiable parts (build, unit test, both review rounds) are green; a real interrupted-sweep resume on mainnet is the human gate I cannot perform.
- **P1-B / W1-1 (+ W1-4) · W1-2 (wallet-file safety)** — ☑ landed: - **P1-B / W1-1 (+ W1-4) · W1-2 (wallet-file safety)** — ☑ landed:
- **W1-1 (High):** `switchToWallet` never checked the target wallet file exists, so a moved/deleted file "opened" as a fresh empty wallet (dragonxd auto-creates for a missing `-wallet=`), looking exactly like fund loss. It now `std::filesystem::exists`-checks `datadir + "/" + walletFile` before switching and blocks with a "not found (moved or deleted?)" warning. Placed before the daemon-stop prompt, and — since the check runs no matter how `switchToWallet` is invoked — it also **closes W1-4** (the stale-switcher-row TOCTOU). - **W1-1 (High):** `switchToWallet` never checked the target wallet file exists, so a moved/deleted file "opened" as a fresh empty wallet (dragonxd auto-creates for a missing `-wallet=`), looking exactly like fund loss. It now `std::filesystem::exists`-checks `datadir + "/" + walletFile` before switching and blocks with a "not found (moved or deleted?)" warning. Placed before the daemon-stop prompt, and — since the check runs no matter how `switchToWallet` is invoked — it also **closes W1-4** (the stale-switcher-row TOCTOU).
- **W1-2 (Med):** `walletOutputLooksCorrupt` matched the generic "Error loading wallet" string, so a `DB_TOO_NEW` (newer-version) wallet was offered a `-salvagewallet` repair that can't fix it. Now the generic match is excluded when the output also contains "newer version". - **W1-2 (Med):** `walletOutputLooksCorrupt` matched the generic "Error loading wallet" string, so a `DB_TOO_NEW` (newer-version) wallet was offered a `-salvagewallet` repair that can't fix it. Now the generic match is excluded when the output also contains "newer version".

View File

@@ -804,6 +804,10 @@ private:
void pumpSeedMigration(); // main thread: pick up background progress/result each frame void pumpSeedMigration(); // main thread: pick up background progress/result each frame
// Phase 2: sweep all legacy funds into the new wallet, then adopt it as the primary wallet. // Phase 2: sweep all legacy funds into the new wallet, then adopt it as the primary wallet.
void refreshSeedMigrationBalance(); // query the legacy total (shown on the Sweep step) void refreshSeedMigrationBalance(); // query the legacy total (shown on the Sweep step)
// W3-3: the terminal callback for the sweep opid, shared by the initial submit and a resume
// re-track. `resumed` selects the failure behaviour: a fresh sweep that fails -> Error; a resumed
// 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 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 beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan

View File

@@ -55,6 +55,7 @@
#include "util/http_download.h" #include "util/http_download.h"
#include "data/exchange_info.h" #include "data/exchange_info.h"
#include "data/exchange_candles.h" #include "data/exchange_candles.h"
#include "data/seed_migration_resume.h"
#include "util/platform.h" #include "util/platform.h"
#include "util/perf_log.h" #include "util/perf_log.h"
#include "util/i18n.h" #include "util/i18n.h"
@@ -4177,27 +4178,56 @@ void App::showSeedMigrationDialog()
// Resume a pending migration. If a sweep was already submitted (txid persisted), resume at the // Resume a pending migration. If a sweep was already submitted (txid persisted), resume at the
// confirm/adopt stage — re-derived from the chain — rather than sweeping again; otherwise start // confirm/adopt stage — re-derived from the chain — rather than sweeping again; otherwise start
// at the Sweep step. With no pending migration, start fresh at the intro. // at the Sweep step. With no pending migration, start fresh at the intro.
if (settings_ && settings_->getSeedMigrationPending() && !settings_->getSeedMigrationDest().empty()) { const bool pending = settings_ && settings_->getSeedMigrationPending();
seed_migration_dest_ = settings_->getSeedMigrationDest(); const bool haveDest = settings_ && !settings_->getSeedMigrationDest().empty();
const std::string sweepTxid = settings_ ? settings_->getSeedMigrationSweepTxid() : std::string();
const std::string sweepOpid = settings_ ? settings_->getSeedMigrationSweepOpid() : std::string();
const bool connected = state_.connected && rpc_ && worker_;
switch (decideSeedMigrationResume(pending, haveDest, sweepTxid, sweepOpid, connected)) {
case MigrationResume::Confirming:
seed_migration_dest_ = settings_->getSeedMigrationDest();
seed_migration_temp_dir_ = settings_->getSeedMigrationTempDir();
seed_migration_sweep_txid_ = sweepTxid;
seed_migration_sweep_confs_ = 0;
seed_migration_legacy_remaining_ = -1.0;
seed_migration_poll_timer_ = 0.0f; // poll immediately
seed_migration_step_ = SeedMigrationStep::Confirming;
break;
case MigrationResume::RetrackOpid:
// W3-3: a sweep opid was submitted but its txid was never persisted (app closed mid-Sweeping).
// Re-track it to recover the txid. If the daemon forgot it (restart), the opid poller flags it
// stale and makeSweepCompletionCallback(resumed) falls back to the Sweep gate — never a hang.
// Only reached when connected (decideSeedMigrationResume), so the poller can actually run and
// the buttonless "Sweeping" spinner is guaranteed an exit.
seed_migration_dest_ = settings_->getSeedMigrationDest();
seed_migration_temp_dir_ = settings_->getSeedMigrationTempDir();
seed_migration_sweep_txid_.clear();
pending_send_callbacks_[sweepOpid] = makeSweepCompletionCallback(/*resumed=*/true);
trackOperation(sweepOpid);
seed_migration_step_ = SeedMigrationStep::Sweeping;
seed_migration_status_ = "Checking on the previous sweep…";
break;
case MigrationResume::SweepGate:
// No txid, and either no opid or not connected to re-track it (a persisted opid is left in
// place so a later reconnect+reopen can re-track it). The Sweep step is dismissable and
// reloads the balance, so the user is never trapped while offline.
seed_migration_dest_ = settings_->getSeedMigrationDest();
seed_migration_temp_dir_ = settings_->getSeedMigrationTempDir(); seed_migration_temp_dir_ = settings_->getSeedMigrationTempDir();
seed_migration_sweep_txid_ = settings_->getSeedMigrationSweepTxid(); seed_migration_sweep_txid_.clear();
if (!seed_migration_sweep_txid_.empty()) { seed_migration_step_ = SeedMigrationStep::Sweep;
seed_migration_sweep_confs_ = 0; seed_migration_balance_loaded_ = false;
seed_migration_legacy_remaining_ = -1.0; seed_migration_nofunds_confirmed_ = false;
seed_migration_poll_timer_ = 0.0f; // poll immediately refreshSeedMigrationBalance();
seed_migration_step_ = SeedMigrationStep::Confirming; break;
} else { case MigrationResume::Intro:
seed_migration_step_ = SeedMigrationStep::Sweep; default:
seed_migration_balance_loaded_ = false;
seed_migration_nofunds_confirmed_ = false;
refreshSeedMigrationBalance();
}
} else {
seed_migration_step_ = SeedMigrationStep::Intro; seed_migration_step_ = SeedMigrationStep::Intro;
// Fresh start: the Intro step will pre-flight the wallet (legacy vs already-seeded vs old // Fresh start: the Intro step will pre-flight the wallet (legacy vs already-seeded vs old
// daemon) before offering to create anything. // daemon) before offering to create anything.
seed_migration_precheck_ = SeedMigrationPrecheck::Pending; seed_migration_precheck_ = SeedMigrationPrecheck::Pending;
seed_migration_precheck_started_ = false; seed_migration_precheck_started_ = false;
break;
} }
} }
@@ -4272,29 +4302,71 @@ void App::beginSweepToSeedWallet()
seed_migration_step_ = SeedMigrationStep::Error; seed_migration_step_ = SeedMigrationStep::Error;
return; return;
} }
pending_send_callbacks_[opid] = [this](bool ok, const std::string& result) { // W3-3: adopt this new opid atomically — persist it AND clear any prior sweep txid in the
if (ok) { // SAME settings write. Persisting the opid lets an app-close during Sweeping (opid
seed_migration_sweep_txid_ = result; // submitted, not yet resolved to a txid) re-poll it on resume instead of dropping it. Doing
// Persist the txid so a restart resumes at the confirm/adopt stage and never // the swap HERE — only once the new submit has succeeded — rather than speculatively at
// re-sweeps from scratch. The Confirming step gates adopt on this tx being mined // function entry means a FAILED "Sweep remaining" remainder re-sweep leaves the
// (>= 1 confirmation) AND the legacy balance dropping to ~0. // already-mined first sweep's txid intact and resumable to Confirming; and the txid and
if (settings_) { settings_->setSeedMigrationSweepTxid(result); settings_->save(); } // opid are never both authoritative at once (torn-write safe; resume checks txid first).
seed_migration_sweep_confs_ = 0; seed_migration_sweep_txid_.clear();
seed_migration_legacy_remaining_ = -1.0; if (settings_) {
seed_migration_poll_timer_ = 0.0f; settings_->setSeedMigrationSweepTxid("");
seed_migration_status_.clear(); settings_->setSeedMigrationSweepOpid(opid);
seed_migration_step_ = SeedMigrationStep::Confirming; settings_->save();
} else { }
seed_migration_status_ = result.empty() ? "The sweep transaction failed." : result; pending_send_callbacks_[opid] = makeSweepCompletionCallback(/*resumed=*/false);
seed_migration_step_ = SeedMigrationStep::Error;
}
};
trackOperation(opid); trackOperation(opid);
seed_migration_status_ = "Waiting for the sweep transaction to be accepted…"; seed_migration_status_ = "Waiting for the sweep transaction to be accepted…";
}; };
}); });
} }
// W3-3: terminal handling for the sweep operation, shared by the initial submit (resumed=false) and
// a resume re-track (resumed=true). On success it persists the txid and clears the opid in the SAME
// settings write, so the txid always outranks the opid on a later resume (torn-write safe).
std::function<void(bool, const std::string&)> App::makeSweepCompletionCallback(bool resumed)
{
return [this, resumed](bool ok, const std::string& result) {
if (ok) {
seed_migration_sweep_txid_ = result;
// Persist the txid (and drop the now-redundant opid) so a restart resumes at the
// confirm/adopt stage and never re-sweeps from scratch. The Confirming step gates adopt on
// this tx being mined (>= 1 confirmation) AND the legacy balance dropping to ~0.
if (settings_) {
settings_->setSeedMigrationSweepTxid(result);
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;
} else if (resumed) {
// A resumed opid the daemon no longer knows (it restarted — the op queue is in-memory
// only). "Stale" can't be told apart from "failed", and the earlier sweep may in fact have
// already broadcast/mined, so DON'T dead-end at Error: drop the stale opid and return to
// the Sweep gate, re-fetching the legacy balance. If that sweep did complete, the balance
// reads ~0 and the Sweep step short-circuits to adopt; otherwise the user can sweep again.
if (settings_) { settings_->setSeedMigrationSweepOpid(""); settings_->save(); }
seed_migration_sweep_txid_.clear();
seed_migration_balance_loaded_ = false;
seed_migration_nofunds_confirmed_ = false;
seed_migration_status_ =
"Couldn't confirm the earlier sweep — it may have already completed. "
"Check your balance below before sweeping again.";
seed_migration_step_ = SeedMigrationStep::Sweep;
refreshSeedMigrationBalance(); // else the Sweep step sits on a permanent "Checking balance…"
} else {
// A fresh sweep that genuinely failed. Clear the persisted opid so it can't mis-resume.
if (settings_) { settings_->setSeedMigrationSweepOpid(""); settings_->save(); }
seed_migration_status_ = result.empty() ? "The sweep transaction failed." : result;
seed_migration_step_ = SeedMigrationStep::Error;
}
};
}
// Confirming step: poll the sweep tx's confirmations + the legacy wallet's remaining balance. The // Confirming step: poll the sweep tx's confirmations + the legacy wallet's remaining balance. The
// adopt step is gated on the tx being mined (confs >= 1) AND the legacy balance being ~0, so we // 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 // never swap wallet.dat while the funds could still bounce back (dropped/reorged tx) or while a
@@ -4458,6 +4530,7 @@ void App::pumpSeedMigration()
settings_->setSeedMigrationDest(""); settings_->setSeedMigrationDest("");
settings_->setSeedMigrationTempDir(""); settings_->setSeedMigrationTempDir("");
settings_->setSeedMigrationSweepTxid(""); settings_->setSeedMigrationSweepTxid("");
settings_->setSeedMigrationSweepOpid(""); // W3-3
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
@@ -4500,6 +4573,11 @@ void App::pumpSeedMigration()
settings_->setSeedMigrationPending(true); settings_->setSeedMigrationPending(true);
settings_->setSeedMigrationDest(seed_migration_dest_); settings_->setSeedMigrationDest(seed_migration_dest_);
settings_->setSeedMigrationTempDir(seed_migration_temp_dir_); settings_->setSeedMigrationTempDir(seed_migration_temp_dir_);
// W3-3: a brand-new migration has done no sweep yet — clear any sweep artifacts left over
// from a prior aborted run so reopening this fresh migration can't mis-resume on a stale
// txid/opid (the resume block reads these whenever the migration is pending).
settings_->setSeedMigrationSweepTxid("");
settings_->setSeedMigrationSweepOpid("");
settings_->save(); settings_->save();
} }
} else { } else {

View File

@@ -238,6 +238,7 @@ bool Settings::load(const std::string& path)
loadScalar(j, "seed_migration_dest", seed_migration_dest_); loadScalar(j, "seed_migration_dest", seed_migration_dest_);
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, "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_);
@@ -505,6 +506,7 @@ bool Settings::save(const std::string& path)
j["seed_migration_dest"] = seed_migration_dest_; j["seed_migration_dest"] = seed_migration_dest_;
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["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_;

View File

@@ -356,6 +356,11 @@ public:
// migration is past the sweep, so a resume goes to the confirm/adopt stage (not sweep again). // migration is past the sweep, so a resume goes to the confirm/adopt stage (not sweep again).
std::string getSeedMigrationSweepTxid() const { return seed_migration_sweep_txid_; } std::string getSeedMigrationSweepTxid() const { return seed_migration_sweep_txid_; }
void setSeedMigrationSweepTxid(const std::string& v) { seed_migration_sweep_txid_ = v; } void setSeedMigrationSweepTxid(const std::string& v) { seed_migration_sweep_txid_ = v; }
// W3-3: the async sweep operation id, persisted while the sweep is in flight (before it resolves
// to a txid). Lets a resume re-poll a mid-sweep interruption instead of dropping the txid. Cleared
// 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_; }
void setSeedMigrationSweepOpid(const std::string& v) { seed_migration_sweep_opid_ = 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_; }
@@ -587,6 +592,7 @@ private:
std::string seed_migration_dest_; std::string seed_migration_dest_;
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_;
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;

View File

@@ -0,0 +1,37 @@
#pragma once
#include <string>
// Pure routing decision for resuming a pending migrate-to-seed flow (finding W3-3). Kept free of
// App/UI/RPC state so the highest-risk branch — where a reopened migration lands — is unit-testable
// and reviewable in isolation. App::showSeedMigrationDialog feeds it the persisted state + live
// connectivity and switches on the result. See src/app_network.cpp.
namespace dragonx {
enum class MigrationResume {
Intro, // no pending migration → start fresh at the intro
Confirming, // a sweep txid is persisted → resume at the confirm/adopt gate (re-derived from chain)
RetrackOpid, // a sweep opid (but no txid yet) is persisted AND we're connected → re-poll it
SweepGate, // otherwise → the dismissable Sweep step (reload balance, offer re-sweep)
};
// Decide where reopening the migration dialog lands.
//
// Invariant: the txid outranks the opid — once a sweep resolves to a txid the opid is cleared in the
// same settings write, so a persisted txid always means "past the sweep". A persisted opid is only
// re-tracked when connected, because the buttonless "Sweeping" spinner relies on the opid poller
// (which needs an RPC connection) to ever exit; disconnected, we fall back to the dismissable Sweep
// gate (which reloads the balance and, if the earlier sweep already drained it, short-circuits to
// adopt) — never trapping the user.
inline MigrationResume decideSeedMigrationResume(bool pending,
bool haveDest,
const std::string& sweepTxid,
const std::string& sweepOpid,
bool connected) {
if (!pending || !haveDest) return MigrationResume::Intro;
if (!sweepTxid.empty()) return MigrationResume::Confirming;
if (!sweepOpid.empty() && connected) return MigrationResume::RetrackOpid;
return MigrationResume::SweepGate;
}
} // namespace dragonx

View File

@@ -35,6 +35,7 @@
#include "ui/node_status_banner.h" #include "ui/node_status_banner.h"
#include "ui/staleness_badge.h" #include "ui/staleness_badge.h"
#include "ui/notifications.h" #include "ui/notifications.h"
#include "data/seed_migration_resume.h"
#include "util/address_validation.h" #include "util/address_validation.h"
#include "util/amount_format.h" #include "util/amount_format.h"
#include "util/payment_uri.h" #include "util/payment_uri.h"
@@ -2687,6 +2688,34 @@ void testNotificationHistory()
EXPECT_EQ((int)(n.totalPushed() - base), 152); // clearing doesn't rewind the counter EXPECT_EQ((int)(n.totalPushed() - base), 152); // clearing doesn't rewind the counter
} }
void testSeedMigrationResume()
{
using dragonx::decideSeedMigrationResume;
using dragonx::MigrationResume;
// No pending migration (or missing dest) → start fresh at the intro.
EXPECT_TRUE(decideSeedMigrationResume(false, false, "", "", true) == MigrationResume::Intro);
EXPECT_TRUE(decideSeedMigrationResume(false, true, "tx", "op", true) == MigrationResume::Intro);
EXPECT_TRUE(decideSeedMigrationResume(true, false, "tx", "op", true) == MigrationResume::Intro);
// A persisted txid outranks everything → resume at the confirm/adopt gate (txid-first invariant).
EXPECT_TRUE(decideSeedMigrationResume(true, true, "tx", "", true) == MigrationResume::Confirming);
EXPECT_TRUE(decideSeedMigrationResume(true, true, "tx", "op", true) == MigrationResume::Confirming);
EXPECT_TRUE(decideSeedMigrationResume(true, true, "tx", "op", false) == MigrationResume::Confirming);
// Opid but no txid, AND connected → re-track the opid (recover the txid / detect stale).
EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "op", true) == MigrationResume::RetrackOpid);
// W3-3 connectivity gate: opid but NOT connected → the dismissable Sweep gate, NOT the buttonless
// Sweeping spinner (whose only exit is the opid poller, which needs a connection). This is the
// trap the review caught.
EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "op", false) == MigrationResume::SweepGate);
// 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, "", "", false) == MigrationResume::SweepGate);
}
void testLoggerFileSink() void testLoggerFileSink()
{ {
using dragonx::util::Logger; using dragonx::util::Logger;
@@ -7077,6 +7106,7 @@ int main()
testNodeStatusBanner(); testNodeStatusBanner();
testStalenessBadge(); testStalenessBadge();
testNotificationHistory(); testNotificationHistory();
testSeedMigrationResume();
testLoggerFileSink(); testLoggerFileSink();
testDaemonLifecycleExecution(); testDaemonLifecycleExecution();
testDaemonLifecycleAdapters(); testDaemonLifecycleAdapters();