From 89d3975eb31284c6434ba5fd020985d6dbb83538 Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 18 Oct 2026 23:20:32 -0500 Subject: [PATCH] feat(migration): second backup acknowledgement + confirm Discard and Sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app.cpp | 128 ++++++++++++++++++++++++++++++++++------------ src/app.h | 6 +++ src/app_sweep.cpp | 5 +- src/util/i18n.cpp | 6 +++ 4 files changed, 110 insertions(+), 35 deletions(-) diff --git a/src/app.cpp b/src/app.cpp index 576743d..e8ff838 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -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; } diff --git a/src/app.h b/src/app.h index ca942b8..7804d24 100644 --- a/src/app.h +++ b/src/app.h @@ -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 diff --git a/src/app_sweep.cpp b/src/app_sweep.cpp index c518321..774e4c6 100644 --- a/src/app_sweep.cpp +++ b/src/app_sweep.cpp @@ -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, diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index aae6678..24619e7 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -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.";