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:
@@ -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".
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
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_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_ = sweepTxid;
|
||||||
if (!seed_migration_sweep_txid_.empty()) {
|
|
||||||
seed_migration_sweep_confs_ = 0;
|
seed_migration_sweep_confs_ = 0;
|
||||||
seed_migration_legacy_remaining_ = -1.0;
|
seed_migration_legacy_remaining_ = -1.0;
|
||||||
seed_migration_poll_timer_ = 0.0f; // poll immediately
|
seed_migration_poll_timer_ = 0.0f; // poll immediately
|
||||||
seed_migration_step_ = SeedMigrationStep::Confirming;
|
seed_migration_step_ = SeedMigrationStep::Confirming;
|
||||||
} else {
|
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_.clear();
|
||||||
seed_migration_step_ = SeedMigrationStep::Sweep;
|
seed_migration_step_ = SeedMigrationStep::Sweep;
|
||||||
seed_migration_balance_loaded_ = false;
|
seed_migration_balance_loaded_ = false;
|
||||||
seed_migration_nofunds_confirmed_ = false;
|
seed_migration_nofunds_confirmed_ = false;
|
||||||
refreshSeedMigrationBalance();
|
refreshSeedMigrationBalance();
|
||||||
}
|
break;
|
||||||
} else {
|
case MigrationResume::Intro:
|
||||||
|
default:
|
||||||
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,27 +4302,69 @@ 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
|
||||||
|
// 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<void(bool, const std::string&)> App::makeSweepCompletionCallback(bool resumed)
|
||||||
|
{
|
||||||
|
return [this, resumed](bool ok, const std::string& result) {
|
||||||
if (ok) {
|
if (ok) {
|
||||||
seed_migration_sweep_txid_ = result;
|
seed_migration_sweep_txid_ = result;
|
||||||
// Persist the txid so a restart resumes at the confirm/adopt stage and never
|
// Persist the txid (and drop the now-redundant opid) so a restart resumes at the
|
||||||
// re-sweeps from scratch. The Confirming step gates adopt on this tx being mined
|
// confirm/adopt stage and never re-sweeps from scratch. The Confirming step gates adopt on
|
||||||
// (>= 1 confirmation) AND the legacy balance dropping to ~0.
|
// this tx being mined (>= 1 confirmation) AND the legacy balance dropping to ~0.
|
||||||
if (settings_) { settings_->setSeedMigrationSweepTxid(result); settings_->save(); }
|
if (settings_) {
|
||||||
|
settings_->setSeedMigrationSweepTxid(result);
|
||||||
|
settings_->setSeedMigrationSweepOpid("");
|
||||||
|
settings_->save();
|
||||||
|
}
|
||||||
seed_migration_sweep_confs_ = 0;
|
seed_migration_sweep_confs_ = 0;
|
||||||
seed_migration_legacy_remaining_ = -1.0;
|
seed_migration_legacy_remaining_ = -1.0;
|
||||||
seed_migration_poll_timer_ = 0.0f;
|
seed_migration_poll_timer_ = 0.0f;
|
||||||
seed_migration_status_.clear();
|
seed_migration_status_.clear();
|
||||||
seed_migration_step_ = SeedMigrationStep::Confirming;
|
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 {
|
} 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_status_ = result.empty() ? "The sweep transaction failed." : result;
|
||||||
seed_migration_step_ = SeedMigrationStep::Error;
|
seed_migration_step_ = SeedMigrationStep::Error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
trackOperation(opid);
|
|
||||||
seed_migration_status_ = "Waiting for the sweep transaction to be accepted…";
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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_;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
37
src/data/seed_migration_resume.h
Normal file
37
src/data/seed_migration_resume.h
Normal 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
|
||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user