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

@@ -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();