From 32be868dbc1da8f0722fa33981b055123d81aec8 Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 2 Aug 2026 22:11:37 -0500 Subject: [PATCH] feat(migrate): persist the sweep opid so a mid-sweep interruption can resume (W3-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/wallet-hardening.md | 8 +- src/app.h | 4 + src/app_network.cpp | 142 ++++++++++++++++++++++++------- src/config/settings.cpp | 2 + src/config/settings.h | 6 ++ src/data/seed_migration_resume.h | 37 ++++++++ tests/test_phase4.cpp | 30 +++++++ 7 files changed, 195 insertions(+), 34 deletions(-) create mode 100644 src/data/seed_migration_resume.h diff --git a/docs/wallet-hardening.md b/docs/wallet-hardening.md index 378e1f5..79994ad 100644 --- a/docs/wallet-hardening.md +++ b/docs/wallet-hardening.md @@ -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-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 | ☑ | | **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 | ☑ | @@ -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. - **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. -- **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: - **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". diff --git a/src/app.h b/src/app.h index fafbba2..215491d 100644 --- a/src/app.h +++ b/src/app.h @@ -804,6 +804,10 @@ private: 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. 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 makeSweepCompletionCallback(bool resumed); void beginSweepToSeedWallet(); // z_mergetoaddress ["ANY_TADDR","ANY_ZADDR"] -> dest void pollSweepStatus(); // Confirming step: poll sweep confirmations + legacy balance void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan diff --git a/src/app_network.cpp b/src/app_network.cpp index d8ccfa4..d423a8b 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -55,6 +55,7 @@ #include "util/http_download.h" #include "data/exchange_info.h" #include "data/exchange_candles.h" +#include "data/seed_migration_resume.h" #include "util/platform.h" #include "util/perf_log.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 // 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. - if (settings_ && settings_->getSeedMigrationPending() && !settings_->getSeedMigrationDest().empty()) { - seed_migration_dest_ = settings_->getSeedMigrationDest(); + const bool pending = settings_ && settings_->getSeedMigrationPending(); + 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_sweep_txid_ = settings_->getSeedMigrationSweepTxid(); - if (!seed_migration_sweep_txid_.empty()) { - seed_migration_sweep_confs_ = 0; - seed_migration_legacy_remaining_ = -1.0; - seed_migration_poll_timer_ = 0.0f; // poll immediately - seed_migration_step_ = SeedMigrationStep::Confirming; - } else { - seed_migration_step_ = SeedMigrationStep::Sweep; - seed_migration_balance_loaded_ = false; - seed_migration_nofunds_confirmed_ = false; - refreshSeedMigrationBalance(); - } - } else { + seed_migration_sweep_txid_.clear(); + seed_migration_step_ = SeedMigrationStep::Sweep; + seed_migration_balance_loaded_ = false; + seed_migration_nofunds_confirmed_ = false; + refreshSeedMigrationBalance(); + break; + case MigrationResume::Intro: + default: seed_migration_step_ = SeedMigrationStep::Intro; // Fresh start: the Intro step will pre-flight the wallet (legacy vs already-seeded vs old // daemon) before offering to create anything. seed_migration_precheck_ = SeedMigrationPrecheck::Pending; seed_migration_precheck_started_ = false; + break; } } @@ -4272,29 +4302,71 @@ void App::beginSweepToSeedWallet() seed_migration_step_ = SeedMigrationStep::Error; return; } - pending_send_callbacks_[opid] = [this](bool ok, const std::string& result) { - if (ok) { - seed_migration_sweep_txid_ = result; - // Persist the txid 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_->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 { - seed_migration_status_ = result.empty() ? "The sweep transaction failed." : result; - seed_migration_step_ = SeedMigrationStep::Error; - } - }; + // W3-3: adopt this new opid atomically — persist it AND clear any prior sweep txid in the + // SAME settings write. Persisting the opid lets an app-close during Sweeping (opid + // submitted, not yet resolved to a txid) re-poll it on resume instead of dropping it. Doing + // the swap HERE — only once the new submit has succeeded — rather than speculatively at + // function entry means a FAILED "Sweep remaining" remainder re-sweep leaves the + // already-mined first sweep's txid intact and resumable to Confirming; and the txid and + // opid are never both authoritative at once (torn-write safe; resume checks txid first). + seed_migration_sweep_txid_.clear(); + if (settings_) { + settings_->setSeedMigrationSweepTxid(""); + settings_->setSeedMigrationSweepOpid(opid); + settings_->save(); + } + pending_send_callbacks_[opid] = makeSweepCompletionCallback(/*resumed=*/false); trackOperation(opid); 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 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 // 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 @@ -4458,6 +4530,7 @@ void App::pumpSeedMigration() settings_->setSeedMigrationDest(""); settings_->setSeedMigrationTempDir(""); settings_->setSeedMigrationSweepTxid(""); + settings_->setSeedMigrationSweepOpid(""); // W3-3 settings_->save(); } 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_->setSeedMigrationDest(seed_migration_dest_); 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(); } } else { diff --git a/src/config/settings.cpp b/src/config/settings.cpp index 612b609..0e95e87 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -238,6 +238,7 @@ bool Settings::load(const std::string& path) loadScalar(j, "seed_migration_dest", seed_migration_dest_); 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_opid", seed_migration_sweep_opid_); loadScalar(j, "auto_lock_timeout", auto_lock_timeout_); loadScalar(j, "unlock_duration", unlock_duration_); 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_temp_dir"] = seed_migration_temp_dir_; 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["unlock_duration"] = unlock_duration_; j["pin_enabled"] = pin_enabled_; diff --git a/src/config/settings.h b/src/config/settings.h index 072cb7e..bc0f748 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -356,6 +356,11 @@ public: // 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_; } 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) int getAutoLockTimeout() const { return auto_lock_timeout_; } @@ -587,6 +592,7 @@ private: std::string seed_migration_dest_; std::string seed_migration_temp_dir_; std::string seed_migration_sweep_txid_; + std::string seed_migration_sweep_opid_; int auto_lock_timeout_ = 900; // 15 minutes int unlock_duration_ = 600; // 10 minutes bool pin_enabled_ = false; diff --git a/src/data/seed_migration_resume.h b/src/data/seed_migration_resume.h new file mode 100644 index 0000000..fe138d9 --- /dev/null +++ b/src/data/seed_migration_resume.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +// 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 diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 17e229b..0b7516d 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -35,6 +35,7 @@ #include "ui/node_status_banner.h" #include "ui/staleness_badge.h" #include "ui/notifications.h" +#include "data/seed_migration_resume.h" #include "util/address_validation.h" #include "util/amount_format.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 } +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() { using dragonx::util::Logger; @@ -7077,6 +7106,7 @@ int main() testNodeStatusBanner(); testStalenessBadge(); testNotificationHistory(); + testSeedMigrationResume(); testLoggerFileSink(); testDaemonLifecycleExecution(); testDaemonLifecycleAdapters();