feat(migration): second backup acknowledgement + confirm Discard and Sweep

Migrate-to-seed is irreversible and fund-moving, so its gates are strengthened
(UI only — the sweep/adopt/wipe logic, z_mergetoaddress args, amounts and
destination are byte-identical):
- Continue now requires a second, consequence-stating acknowledgement checkbox
  (funds move and are recoverable only via the seed phrase), not just "I wrote
  it down".
- Discard, Sweep-all and Sweep-remaining each take a second confirming click
  (stating the amount being moved) instead of firing on one click; the confirm
  body runs verbatim only after confirmation, and arming never survives a
  close/reopen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 23:20:32 -05:00
co-authored by Claude Opus 4.8
parent 3f1838cfdd
commit 89d3975eb3
4 changed files with 110 additions and 35 deletions
+94 -34
View File
@@ -4762,6 +4762,9 @@ void App::renderSeedMigrationDialog()
if (!seed_migration_seed_.empty())
sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size());
seed_migration_seed_.clear();
// Disarm the inline confirms so an armed state can never survive a close/reopen.
seed_migration_confirm_discard_ = false;
seed_migration_confirm_sweep_ = false;
};
auto close = [this, &wipeSeed]() { wipeSeed(); show_seed_migration_ = false; };
@@ -4895,11 +4898,15 @@ void App::renderSeedMigrationDialog()
}
ImGui::Spacing();
ImGui::Checkbox(TR("mig_backed_up"), &seed_migration_backed_up_);
ImGui::Checkbox(TR("mig_understood"), &seed_migration_understood_);
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
ImGui::TextWrapped("%s", TR("mig_step1_done"));
ImGui::PopStyleColor();
if (!seed_migration_backed_up_) ImGui::BeginDisabled();
// Continue requires BOTH acknowledgements, and is suppressed while the Discard confirm is armed.
const bool ackOk = seed_migration_backed_up_ && seed_migration_understood_;
const bool continueDisabled = !ackOk || seed_migration_confirm_discard_;
if (continueDisabled) ImGui::BeginDisabled();
if (ui::material::TactileButton(TR("mig_continue_sweep"), ImVec2(170 * dp, 0))) {
wipeSeed(); // the sweep/adopt steps only use the address, never the seed itself
seed_migration_step_ = SeedMigrationStep::Sweep;
@@ -4907,23 +4914,40 @@ void App::renderSeedMigrationDialog()
seed_migration_nofunds_confirmed_ = false;
refreshSeedMigrationBalance();
}
if (!seed_migration_backed_up_) ImGui::EndDisabled();
if (continueDisabled) ImGui::EndDisabled();
if (!ackOk && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
ui::material::Tooltip("%s", TR("mig_ack_required"));
ImGui::SameLine();
if (ui::material::TactileButton(TR("mig_discard"), ImVec2(160 * dp, 0))) {
// Throw away the isolated new wallet + clear the pending state.
if (!seed_migration_temp_dir_.empty()) {
std::error_code ec;
std::filesystem::remove_all(seed_migration_temp_dir_, ec);
// Discard now takes a second click: first click arms a confirm, since it deletes the new wallet.
if (!seed_migration_confirm_discard_) {
if (ui::material::TactileButton(TR("mig_discard"), ImVec2(160 * dp, 0)))
seed_migration_confirm_discard_ = true;
}
if (seed_migration_confirm_discard_) {
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::ReadableError()));
ImGui::TextWrapped("%s", TR("mig_discard_confirm"));
ImGui::PopStyleColor();
if (ui::material::TactileButton(TR("mig_discard_yes"), ImVec2(160 * dp, 0))) {
// Throw away the isolated new wallet + clear the pending state. (body unchanged)
if (!seed_migration_temp_dir_.empty()) {
std::error_code ec;
std::filesystem::remove_all(seed_migration_temp_dir_, ec);
}
if (settings_) {
settings_->setSeedMigrationPending(false);
settings_->setSeedMigrationDest("");
settings_->setSeedMigrationTempDir("");
settings_->save();
}
seed_migration_temp_dir_.clear();
seed_migration_dest_.clear();
seed_migration_confirm_discard_ = false;
close();
}
if (settings_) {
settings_->setSeedMigrationPending(false);
settings_->setSeedMigrationDest("");
settings_->setSeedMigrationTempDir("");
settings_->save();
}
seed_migration_temp_dir_.clear();
seed_migration_dest_.clear();
close();
ImGui::SameLine();
if (ui::material::TactileButton(TR("cancel"), ImVec2(110 * dp, 0)))
seed_migration_confirm_discard_ = false;
}
break;
}
@@ -4999,17 +5023,35 @@ void App::renderSeedMigrationDialog()
ImGui::Spacing();
{
const bool canSweep = !state_.isLocked() && seed_migration_balance_ > 0.0;
if (!canSweep) ImGui::BeginDisabled();
if (ui::material::TactileButton(TR("mig_sweep_all"), ImVec2(170 * dp, 0)))
beginSweepToSeedWallet();
if (!canSweep) ImGui::EndDisabled();
if (!canSweep) seed_migration_confirm_sweep_ = false; // never keep a stale armed state
if (!seed_migration_confirm_sweep_) {
if (!canSweep) ImGui::BeginDisabled();
if (ui::material::TactileButton(TR("mig_sweep_all"), ImVec2(170 * dp, 0)))
seed_migration_confirm_sweep_ = true; // first click arms the confirm
if (!canSweep) ImGui::EndDisabled();
ImGui::SameLine();
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
refreshSeedMigrationBalance();
ImGui::SameLine();
if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0)))
close(); // pending state persists; resumes here next time
} else {
// Armed: state the amount being moved before the single sweep transaction fires.
char cbuf[160]; snprintf(cbuf, sizeof(cbuf), TR("mig_sweep_confirm"), seed_migration_balance_);
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::ReadableError()));
ImGui::TextWrapped("%s", cbuf);
ImGui::PopStyleColor();
if (!canSweep) ImGui::BeginDisabled();
if (ui::material::TactileButton(TR("mig_sweep_confirm_yes"), ImVec2(170 * dp, 0))) {
seed_migration_confirm_sweep_ = false;
beginSweepToSeedWallet(); // unchanged
}
if (!canSweep) ImGui::EndDisabled();
ImGui::SameLine();
if (ui::material::TactileButton(TR("cancel"), ImVec2(110 * dp, 0)))
seed_migration_confirm_sweep_ = false;
}
}
ImGui::SameLine();
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
refreshSeedMigrationBalance();
ImGui::SameLine();
if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0)))
close(); // pending state persists; resumes here next time
break;
}
@@ -5049,17 +5091,35 @@ void App::renderSeedMigrationDialog()
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
ImGui::TextWrapped("%s", TR("mig_remainder"));
ImGui::PopStyleColor();
if (ui::material::TactileButton(TR("mig_sweep_remaining"), ImVec2(180 * dp, 0)))
beginSweepToSeedWallet();
if (!seed_migration_confirm_sweep_) {
if (ui::material::TactileButton(TR("mig_sweep_remaining"), ImVec2(180 * dp, 0)))
seed_migration_confirm_sweep_ = true; // first click arms
} else {
char rcbuf[160];
snprintf(rcbuf, sizeof(rcbuf), TR("mig_sweep_confirm"), seed_migration_legacy_remaining_);
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::ReadableError()));
ImGui::TextWrapped("%s", rcbuf);
ImGui::PopStyleColor();
if (ui::material::TactileButton(TR("mig_sweep_confirm_yes"), ImVec2(180 * dp, 0))) {
seed_migration_confirm_sweep_ = false;
beginSweepToSeedWallet(); // unchanged
}
ImGui::SameLine();
if (ui::material::TactileButton(TR("cancel"), ImVec2(110 * dp, 0)))
seed_migration_confirm_sweep_ = false;
}
} else {
ImGui::TextColored(kMedium, "%s", TR("mig_waiting_mine"));
}
ImGui::SameLine();
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
pollSweepStatus(/*forceBalance=*/true);
ImGui::SameLine();
if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0)))
close(); // pending state + txid persist; resumes here next time
// Refresh / Later are hidden while the sweep confirm is armed, to keep that row uncluttered.
if (!seed_migration_confirm_sweep_) {
ImGui::SameLine();
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
pollSweepStatus(/*forceBalance=*/true);
ImGui::SameLine();
if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0)))
close(); // pending state + txid persist; resumes here next time
}
break;
}
+6
View File
@@ -927,6 +927,8 @@ private:
// curve on every failed path (a PIN RPC failure used to skip it).
void applyUnlockSuccess(const std::string& passphrase, int timeout);
void applyUnlockFailure(const std::string& errorMessage);
// Restore the persisted unlock-failure count + lockout (once per lock-screen session, per wallet).
void restoreLockoutState();
// Non-credential failure (busy node, unknown error): message only, no attempt counted.
void applyUnlockNotCounted(const std::string& errorMessage);
// Clear all rescan + witness-rebuild progress/accumulator state. Shared by the four
@@ -1453,6 +1455,9 @@ private:
std::string seed_migration_dest_; // new shielded z-address (Phase 2 sweep target)
std::string seed_migration_temp_dir_; // temp datadir root holding the new wallet (kept)
bool seed_migration_backed_up_ = false; // "I've written it down" confirmation
bool seed_migration_understood_ = false; // 2nd ack: funds recoverable only via the phrase
bool seed_migration_confirm_discard_ = false; // inline "are you sure" gate for Discard (never persisted)
bool seed_migration_confirm_sweep_ = false; // inline "are you sure" gate for Sweep (never persisted)
std::string seed_migration_status_; // main-thread display (progress / error text)
double seed_migration_balance_ = 0.0; // legacy total to sweep (shown on the Sweep step)
std::string seed_migration_sweep_txid_; // the z_mergetoaddress sweep transaction id
@@ -1973,6 +1978,7 @@ private:
int lock_attempts_ = 0;
float lock_lockout_timer_ = 0.0f;
std::chrono::steady_clock::time_point lock_timer_armed_at_{}; // when lock_error/lockout timers were last armed
bool lock_state_restored_ = false; // one-shot: persisted lockout restored for this lock-screen session
bool lock_unlock_in_progress_ = false;
// Encrypt wallet dialog state
+4 -1
View File
@@ -354,6 +354,9 @@ void App::clearDemoWalletData()
seed_migration_precheck_ = SeedMigrationPrecheck::Pending;
seed_migration_precheck_started_ = false;
seed_migration_backed_up_ = false;
seed_migration_understood_ = false;
seed_migration_confirm_discard_ = false;
seed_migration_confirm_sweep_ = false;
seed_migration_balance_ = 0.0;
seed_migration_balance_loaded_ = false;
seed_migration_nofunds_confirmed_ = false;
@@ -623,7 +626,7 @@ void App::buildSweepCatalog()
a.seed_migration_precheck_started_ = true;
});
mig("modal-migrate-showseed", SeedMigrationStep::ShowSeed,
[](App& a) { a.seed_migration_seed_ = kDemoMnemonic; a.seed_migration_backed_up_ = false; });
[](App& a) { a.seed_migration_seed_ = kDemoMnemonic; a.seed_migration_backed_up_ = false; a.seed_migration_understood_ = false; });
mig("modal-migrate-sweep", SeedMigrationStep::Sweep,
[](App& a) { a.seed_migration_balance_ = 15.75000000; a.seed_migration_balance_loaded_ = true; });
mig("modal-migrate-nofunds", SeedMigrationStep::Sweep,
+6
View File
@@ -474,6 +474,8 @@ void I18n::loadBuiltinEnglish()
strings_["mig_receive_addr"] = "New wallet receive address:";
strings_["mig_copy_seed"] = "Copy seed";
strings_["mig_backed_up"] = "I've written down my seed phrase";
strings_["mig_understood"] = "I understand that once I continue, my funds will be moved into this new wallet and can only be recovered with this seed phrase";
strings_["mig_ack_required"] = "Confirm both statements above to continue";
strings_["mig_step1_done"] = "Step 1 of 2 complete — your funds have NOT moved yet. Sweeping them into this new wallet is the next step.";
strings_["mig_continue_sweep"] = "Continue to sweep";
strings_["mig_discard"] = "Discard migration";
@@ -491,6 +493,10 @@ void I18n::loadBuiltinEnglish()
strings_["mig_adopt"] = "Make this my wallet";
strings_["mig_remainder"] = "Some funds are still in your old wallet (too many inputs for one transaction) — sweep the remainder before switching.";
strings_["mig_sweep_remaining"] = "Sweep remaining";
strings_["mig_discard_confirm"] = "Discard this migration? The new seed wallet created on this device will be deleted. Make sure you have not sent anything to it. Your current wallet is not changed.";
strings_["mig_discard_yes"] = "Yes, discard";
strings_["mig_sweep_confirm"] = "Send %.8f DRGX from your current wallet to the new seed wallet in a single transaction? This moves all of your funds.";
strings_["mig_sweep_confirm_yes"] = "Yes, sweep funds";
strings_["mig_waiting_mine"] = "Waiting for the sweep transaction to be mined…";
strings_["mig_adopting"] = "Installing your new wallet and rescanning…";
strings_["mig_done"] = "Migration complete. Your wallet is now backed by your seed phrase.";