fix(sweep): keep the UI sweep away from real wallet data

The full UI sweep (dev builds only) ran its demo surfaces against the
real profile. Its teardown deleted wallet-{cold,trading,mining,donations,
2023,payroll}.dat from the real datadir, it created zero-filled wallet
files and picker-demo/extra-wallets folders, its demo wallet-index upsert
re-minted the active wallet's scopeId (hiding "w:"-scoped contacts), the
Contacts tab could re-attach legacy-scoped contacts to such a throwaway
id in addressbook.json, and it persisted the demo active wallet file and
Mining pool worker.

- Preflight: a full sweep is refused (toast + log line) while a real
  workflow is in flight: a pending seed_migration_* setting or open
  migrate dialog, a wallet switch, an encryption/import workflow, an
  encrypt/PIN/lock operation, the seed-backup dialog, or a bootstrap.
  The DRAGONX_FULL_SWEEP hook then exits as for an empty catalog.
- Persistence latches: AddressBook (snapshot on the first sweepBegin,
  save/load latched while a demo-contacts surface is shown), WalletIndex
  and Settings (active wallet file, contacts view mode, pool URL/worker)
  for the whole sweep. Settings::sweepEnd() is the last statement of
  clearDemoWalletData and flushes once only if a save was suppressed; a
  quit mid-sweep writes nothing to the profile (the 0700 sandbox, with
  zero-filled demo files only, may stay in the temp dir).
- Scope ids: during a full sweep activeWalletScopeId() returns the
  persisted id or "" and never mints one, so the Contacts tab cannot
  move legacy-scoped contacts to a throwaway id. The only address-book
  write a sweep can still cause is the one-time legacy re-attach to the
  wallet's persisted id, which opening Contacts does anyway.
- Sandbox: demo wallet files and folders go to a fresh random 0700 dir
  under the temp dir (exclusive create, a pre-existing path or symlink
  is refused), removed at sweep end. The teardown that deleted those six
  wallet files is gone.
- Cleanup: the migrate-to-seed state, wizard phase, demo chat
  conversations and a fabricated demo chat identity are reset at sweep
  end; the seed-backup surface always shows the demo phrase and a real
  z_exportmnemonic result is dropped while the sweep runs.
- i18n: one new key (screenshot_sweep_refused) in i18n.cpp and the 8
  language files.

Tests: the address book snapshots only on the first sweepBegin, keeps
addressbook.json byte-identical while latched and restores the real
book; the wallet index keeps wallets.json and the real scope id, also
with the background writer attached; Settings restores the latched
values and flushes once only when a save was suppressed; the sandbox
helper refuses a pre-existing path or symlink and removes only a
prefixed dir, never a symlink's target.

Release note: v2.0.x UI sweeps could delete wallet-{cold,trading,mining,
donations,2023,payroll}.dat from the data directory and re-scope
contacts; this cannot be undone automatically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 13:22:00 -05:00
co-authored by Claude Opus 5.5
parent 3ac46abe1a
commit 62a5577491
22 changed files with 625 additions and 40 deletions
+151 -26
View File
@@ -34,8 +34,13 @@
#include "ui/windows/address_transfer_dialog.h"
#include "ui/windows/key_export_dialog.h"
#include "ui/windows/contacts_tab.h"
#include "ui/windows/chat_tab.h"
#include "ui/windows/folder_picker.h"
#include "ui/pages/settings_page.h"
#include "chat/chat_protocol.h"
#include "util/i18n.h"
#include "util/platform.h"
#include "util/private_temp_dir.h"
#include "wallet/wallet_capabilities.h"
#include "imgui.h"
@@ -95,12 +100,11 @@ const char* kDemoZAddr =
const char* kDemoTxid = "b647077c471fdd2877d35c9d8b70e3c785547fae618458b4068d913ee3d1dc4f";
// Contacts-view sweep: seed a few demo contacts (Z + T types, some global) so the Cards/List/Table
// modes render with data, and restore the real book afterward. Uses sweepSetEntries (no disk write),
// so the user's persisted address book is never touched even if the sweep is interrupted.
std::vector<data::AddressBookEntry> s_contactsSweepBackup;
// modes render with data, and restore the real book afterward. sweepBegin() snapshots the real book on
// its first call only (this setup re-runs every frame) and latches save()/load(), so the user's
// persisted address book is never touched, even if the sweep is interrupted.
void seedSweepContacts(App& a)
{
s_contactsSweepBackup = a.addressBook().entries();
data::AddressBookEntry e1("drgx pool payout address", kDemoZAddr, "mining pool payouts"); // Z, global
e1.avatar = "icon:account_balance"; // icon avatar (verifies the icon-badge render path)
data::AddressBookEntry e2("mining rig", "t1DemoTransparentAddressForUiSweep00000", ""); // T (mining only)
@@ -109,14 +113,17 @@ void seedSweepContacts(App& a)
e2.scope = a.activeWalletIdentityHash();
data::AddressBookEntry e3("cold savings",
"zs1sweepdemocoldsavingsaddressforuicapture0000000000000000000000000000", "long-term storage"); // Z, global
a.addressBook().sweepSetEntries({ e1, e2, e3 });
a.addressBook().sweepBegin({ e1, e2, e3 });
}
// The contacts view mode is restored by Settings::sweepEnd() at sweep end.
void restoreSweepContacts(App& a)
{
a.addressBook().sweepSetEntries(s_contactsSweepBackup);
s_contactsSweepBackup.clear();
if (a.settings()) a.settings()->setContactsViewMode(0);
a.addressBook().sweepEnd();
}
// The full sweep's sandbox: a fresh owner-only dir under the temp dir. Its demo wallet files live in
// <sandbox>/DRAGONX (and DRAGONX-many, extra-wallets, picker-demo), never in the real datadir or config dir.
const char* kSweepSandboxPrefix = "obsidiandragon-sweep-";
} // namespace
std::string App::screenshotDir() const
@@ -132,6 +139,11 @@ std::string App::screenshotFullDir() const
// ── Demo data (full sweep only): make every data-dependent screen render offline ─────────────
void App::installDemoWalletData()
{
// Persistence latches first: until clearDemoWalletData(), nothing the demo surfaces put into
// Settings or the wallet index (or, via seedSweepContacts, the address book) can reach disk.
if (settings_) settings_->sweepBegin();
wallet_index_.sweepBegin();
// Snapshot the state_ fields we mutate (WalletState isn't copy-assignable — reference aliases).
auto& s = sweep_state_snapshot_;
s.connected = state_.connected; s.warming_up = state_.warming_up;
@@ -146,6 +158,7 @@ void App::installDemoWalletData()
s.transactions = state_.transactions;
s.market_price_usd = state_.market.price_usd;
s.market_change_24h = state_.market.change_24h;
s.wizard_phase = wizard_phase_;
s.valid = true;
applyHealthyDemoState();
@@ -229,7 +242,7 @@ void App::installDemoWalletData()
settings_->setPortfolioStyle(0);
}
seedChatDemoData();
sweep_chat_identity_fabricated_ = seedChatDemoData();
}
void App::applyHealthyDemoState()
@@ -247,6 +260,30 @@ void App::applyHealthyDemoState()
wizard_phase_ = WizardPhase::None;
}
// Refuse a full sweep while any real workflow it would fake is in flight: its surfaces overwrite the
// migration, wallet-switch, security-workflow and seed-backup state in memory, and its teardown resets it.
const char* App::fullSweepBlockedReason() const
{
if (settings_ && (settings_->getSeedMigrationPending() || settings_->getSeedMigrationSweepSubmitted() ||
!settings_->getSeedMigrationSweepTxid().empty() ||
!settings_->getSeedMigrationSweepOpid().empty()))
return "a migrate-to-seed is pending (seed_migration_* settings)";
if (show_seed_migration_ || seed_migration_step_ != SeedMigrationStep::Intro || seed_migration_in_flight_)
return "the migrate-to-seed dialog is open";
if (wallet_switch_phase_.load() != 0 || wallet_switch_dialog_open_.load() ||
!pending_switch_wallet_file_.empty())
return "a wallet switch is in progress";
if (wallet_security_workflow_.inProgress() || wallet_security_workflow_.importActive())
return "an encryption workflow is in progress";
if (encrypt_in_progress_ || pin_in_progress_ || lock_unlock_in_progress_)
return "an encrypt, PIN or lock/unlock operation is in progress";
if (show_seed_backup_ || !seed_backup_phrase_.empty())
return "the seed-phrase backup dialog is open";
if (bootstrap_ || bootstrap_downloading_)
return "a bootstrap is in progress";
return nullptr;
}
void App::clearDemoWalletData()
{
auto& s = sweep_state_snapshot_;
@@ -267,8 +304,63 @@ void App::clearDemoWalletData()
state_.market.change_24h = s.market_change_24h;
state_.market.price_history = g_marketHistorySnapshot;
g_marketHistorySnapshot.clear();
wizard_phase_ = s.wizard_phase; // applyHealthyDemoState() dismissed it
s = SweepStateSnapshot{}; // invalidate
// Sandbox: close what shows it, then delete it (error_code overloads only; at worst temp debris,
// e.g. a file a Windows probe thread still holds).
ui::WalletsDialog::hide();
ui::FolderPicker::close();
ui::WalletsDialog::setSweepDataDir({});
if (!sweep_sandbox_.empty()) {
if (!util::removePrivateTempDir(sweep_sandbox_, kSweepSandboxPrefix))
DEBUG_LOGF("[Sweep] could not fully remove the sandbox %s\n", sweep_sandbox_.c_str());
sweep_sandbox_.clear();
}
// Migrate-to-seed state the mig() surfaces faked. Safe to reset: the preflight refused the sweep
// while a real migration was pending or its dialog was open.
show_seed_migration_ = false;
seed_migration_step_ = SeedMigrationStep::Intro;
if (!seed_migration_seed_.empty())
sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size());
seed_migration_seed_.clear();
seed_migration_dest_.clear();
seed_migration_sweep_txid_.clear();
seed_migration_status_.clear();
seed_migration_precheck_ = SeedMigrationPrecheck::Pending;
seed_migration_precheck_started_ = false;
seed_migration_backed_up_ = false;
seed_migration_balance_ = 0.0;
seed_migration_balance_loaded_ = false;
seed_migration_nofunds_confirmed_ = false;
seed_migration_sweep_confs_ = 0;
seed_migration_legacy_remaining_ = -1.0;
// Chat: drop the demo conversations (in-memory only). If the sweep had to fabricate the demo
// identity, drop it too and re-arm provisioning, or chat would run on that throwaway key for the rest
// of the session. No real identity existed, so the DB was never unlocked under it.
if (chat::hushChatFeatureEnabledAtBuild()) {
for (const char* cid : {"demo-1", "demo-2", "demo-3"})
chat_service_.store().eraseConversation(cid);
if (sweep_chat_identity_fabricated_) {
chat_service_.clearIdentity();
chat_service_.store().clear();
chat_seen_watermark_.clear();
ui::ResetChatTab();
chat_db_.lock();
chat_identity_provisioned_ = false;
chat_identity_fetch_in_flight_ = false;
chat_identity_unavailable_ = false;
++chat_session_generation_; // discard any identity fetch that was in flight
}
}
sweep_chat_identity_fabricated_ = false;
// Address book and wallet index: back to the real data (no disk write).
addressBook().sweepEnd();
wallet_index_.sweepEnd();
// Restore the user's real portfolio (demo groups were in-memory only).
if (g_pfSnapshotValid && settings_) {
settings_->setPortfolioEntries(g_pfSnapshot);
@@ -276,6 +368,10 @@ void App::clearDemoWalletData()
}
g_pfSnapshot.clear();
g_pfSnapshotValid = false;
// LAST: restore the latched settings and flush once if a save was suppressed. Anything restored
// after this point would be flushed with its demo value.
if (settings_) settings_->sweepEnd();
}
// ── Catalog ──────────────────────────────────────────────────────────────────────────────────
@@ -457,7 +553,13 @@ void App::buildSweepCatalog()
a.show_seed_backup_ = true; a.seed_backup_fetch_started_ = true;
a.seed_backup_loading_ = false; a.seed_backup_no_mnemonic_ = false;
a.seed_backup_status_.clear();
if (a.seed_backup_phrase_.empty()) a.seed_backup_phrase_ = kDemoMnemonic;
// Always the demo phrase: anything else here could be a real mnemonic, and it would be
// written into this surface's screenshots for every skin.
if (a.seed_backup_phrase_ != kDemoMnemonic) {
if (!a.seed_backup_phrase_.empty())
sodium_memzero(&a.seed_backup_phrase_[0], a.seed_backup_phrase_.size());
a.seed_backup_phrase_ = kDemoMnemonic;
}
},
[](App& a) {
a.show_seed_backup_ = false; a.seed_backup_fetch_started_ = false;
@@ -511,12 +613,13 @@ void App::buildSweepCatalog()
mig("modal-migrate-error", SeedMigrationStep::Error,
[](App& a) { a.seed_migration_status_ = "Example error: the daemon did not respond."; });
// Multi-wallet: the wallet-files list. Drop a couple of demo wallet files in the (throwaway)
// datadir + cache their metadata so the list renders populated.
// Multi-wallet: the wallet-files list. Drop a couple of demo wallet files in the sandbox's
// datadir + cache their metadata (latched, never saved) so the list renders populated.
add("modal-wallets", ui::NavPage::Settings,
[](App& a) {
if (a.sweep_sandbox_.empty()) return;
std::error_code ec;
const std::string dd = util::Platform::getDragonXDataDir();
const std::string dd = a.sweep_sandbox_ + "/DRAGONX";
fs::create_directories(dd, ec);
if (!fs::exists(dd + "/wallet.dat", ec))
std::ofstream(dd + "/wallet.dat", std::ios::binary) << std::string(122880, '\0');
@@ -531,26 +634,26 @@ void App::buildSweepCatalog()
a.walletIndex().upsert(e1);
a.walletIndex().upsert(e2);
// An out-of-datadir wallet (in an extra folder) so the Import action shows too.
const std::string extra = util::Platform::getConfigDir() + "extra-wallets";
const std::string extra = a.sweep_sandbox_ + "/extra-wallets";
fs::create_directories(extra, ec);
if (!fs::exists(extra + "/my-wallet.dat", ec))
std::ofstream(extra + "/my-wallet.dat", std::ios::binary) << std::string(80000, '\0');
a.walletIndex().addExtraFolder(extra);
if (a.settings()) a.settings()->setActiveWalletFile("wallet.dat");
ui::WalletsDialog::setSweepDataDir(dd);
ui::WalletsDialog::show(&a);
},
[](App&) { ui::WalletsDialog::hide(); });
// Many wallets — exercises the viewport-cap path: the card can't fit all rows, so the list
// must scroll internally while the create/scan/footer controls stay pinned (esp. at 150%).
// Teardown removes the extra files it dropped so the plain modal-wallets surface stays a
// clean 3-wallet reference (both surfaces share the one throwaway datadir).
static const char* kManyExtra[] = {"wallet-cold.dat", "wallet-trading.dat", "wallet-mining.dat",
"wallet-donations.dat", "wallet-2023.dat", "wallet-payroll.dat"};
// Its own sandbox datadir keeps the plain modal-wallets surface a clean 3-wallet reference;
// nothing is ever deleted here (the whole sandbox goes at sweep end).
add("modal-wallets-many", ui::NavPage::Settings,
[](App& a) {
if (a.sweep_sandbox_.empty()) return;
std::error_code ec;
const std::string dd = util::Platform::getDragonXDataDir();
const std::string dd = a.sweep_sandbox_ + "/DRAGONX-many";
fs::create_directories(dd, ec);
const char* names[] = {"wallet.dat", "wallet-savings.dat", "wallet-cold.dat",
"wallet-trading.dat", "wallet-mining.dat", "wallet-donations.dat",
@@ -564,14 +667,10 @@ void App::buildSweepCatalog()
a.walletIndex().upsert(e);
}
if (a.settings()) a.settings()->setActiveWalletFile("wallet.dat");
ui::WalletsDialog::setSweepDataDir(dd);
ui::WalletsDialog::show(&a);
},
[](App& a) {
ui::WalletsDialog::hide();
std::error_code ec;
const std::string dd = util::Platform::getDragonXDataDir();
for (const char* n : kManyExtra) fs::remove(dd + "/" + n, ec);
});
[](App&) { ui::WalletsDialog::hide(); });
}
// First-run wizard — one per meaningful phase (full-node only; blocks all other UI while shown).
@@ -702,13 +801,15 @@ void App::buildSweepCatalog()
// list shows sub-folders + wallet files, then open the wallets dialog + the picker over it.
add("modal-folder-picker", ui::NavPage::Settings,
[](App& a) {
if (a.sweep_sandbox_.empty()) return;
std::error_code ec;
const std::string demo = util::Platform::getConfigDir() + "picker-demo";
const std::string demo = a.sweep_sandbox_ + "/picker-demo";
for (const char* sub : {"Documents", "Downloads", "Backups", "wallet-archive"})
fs::create_directories(demo + "/" + sub, ec);
for (const char* f : {"wallet-cold.dat", "wallet-2023.dat"})
if (!fs::exists(demo + "/" + f, ec))
std::ofstream(demo + "/" + f, std::ios::binary) << std::string(66000, '\0');
ui::WalletsDialog::setSweepDataDir(a.sweep_sandbox_ + "/DRAGONX");
ui::WalletsDialog::show(&a);
ui::FolderPicker::open(demo, [](const std::string&) {});
},
@@ -728,6 +829,15 @@ void App::startSweepImpl(bool full)
// Sentinel: a release binary must not contain this literal (strings <bin> | grep -c ... == 0).
DEBUG_LOGF("[Sweep] DRAGONX_UI_SWEEP_COMPILED_IN\n");
// The full sweep fakes wallet, migration, switch and security state: never over a real workflow.
if (full) {
if (const char* why = fullSweepBlockedReason()) {
DEBUG_LOGF("[Sweep] full sweep refused: %s\n", why);
ui::Notifications::instance().warning(TR("screenshot_sweep_refused"));
return;
}
}
sweep_skins_.clear();
for (const auto& sk : ui::schema::SkinManager::instance().available())
if (sk.valid) sweep_skins_.push_back(sk.id);
@@ -744,6 +854,21 @@ void App::startSweepImpl(bool full)
std::string sweepOnlyStr = sweepOnly ? sweepOnly : "";
if (!sweepOnlyStr.empty()) sweep_skins_.assign(1, std::string("dark"));
if (full) {
// A fresh private sandbox for the demo wallet files: never the real datadir or config dir.
std::error_code tec;
const fs::path tmp = fs::temp_directory_path(tec);
sweep_sandbox_ = tec ? std::string() : util::makePrivateTempDir(tmp, kSweepSandboxPrefix);
if (sweep_sandbox_.empty()) {
DEBUG_LOGF("[Sweep] full sweep refused: could not create a private sandbox under %s\n",
tmp.string().c_str());
ui::Notifications::instance().warning(TR("screenshot_sweep_refused"));
return;
}
ui::WalletsDialog::setSweepDataDir(sweep_sandbox_ + "/DRAGONX");
DEBUG_LOGF("[Sweep] sandbox %s\n", sweep_sandbox_.c_str());
}
sweep_full_ = full;
if (full) { capture_mode_ = true; installDemoWalletData(); }
buildSweepCatalog();