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:
@@ -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<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
|
||||
// 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 {
|
||||
|
||||
Reference in New Issue
Block a user