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

@@ -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 {