feat(settings): sweep a private key's funds to your own wallet
Add a "Sweep to my wallet (don't keep the key)" option to the Import Private Key dialog (spending keys only). Rather than keeping the key, it imports the key, then sends ALL its funds to a destination you control — a freshly generated shielded address by default, or an existing wallet address you pick. Flow (App::sweepPrivateKey): import the key (rescan; the stock node has no address index, so its UTXOs/notes can't be enumerated otherwise) → confirm it holds spendable funds → resolve the destination → z_sendmany balance-minus-fee from the key's address to the destination, via the existing tested submitZSendMany wrapper + async-operation tracker. The imported key is left in the wallet with an empty balance (there is no remove-key RPC) — stated plainly in the UI caveat. Design notes forced by this daemon (verified against external/dragonx): - z_importkey returns null here, so the shielded source address is found by diffing z_listaddresses before/after import (aborts if the pre-snapshot fails, so a wrong address can never be picked). Transparent uses importprivkey's return. - z_sendmany, NOT z_mergetoaddress: moves a single-UTXO transparent source (the common paper-wallet case) and fails loudly instead of silently leaving a remainder. Amount computed in integer satoshis for an exact fixed-decimal string. - Clear errors for no-funds / unconfirmed / dust-below-fee / already-imported. - The mode + destination are locked during a running sweep; a sweep that finishes while the dialog is closed still shows its Done/Error+txid on reopen; sweep UI can't bleed into the watch-only viewing dialog. i18n: 8 new sweep_* keys (en + 8 langs); dynamic status strings are English, matching the beginSweepToSeedWallet precedent. Reviewed across three adversarial multi-agent rounds (fund-safety focus): round 1 caught the feature was broken for shielded keys (z_importkey null) and rejected single-UTXO transparent sources; round 2 caught reopen/double-submit/precision issues; round 3 verified the fixes. NOT yet exercised against a live daemon — needs a small-amount mainnet test before it's trusted, like migrate-to-seed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
146
src/app.cpp
146
src/app.cpp
@@ -2919,7 +2919,22 @@ void App::renderImportKeyDialog()
|
||||
import_result_address_.clear();
|
||||
import_key_reveal_ = false;
|
||||
sodium_memzero(import_key_scan_height_, sizeof(import_key_scan_height_));
|
||||
// Preserve a sweep that is still Running OR has a terminal result yet to be seen (a sweep can
|
||||
// finish while the dialog is closed) — reset only when there's genuinely nothing to show, so a
|
||||
// completed sweep's Done/Error panel survives a close+reopen. It's cleared on close (below).
|
||||
if (sweep_step_ == SweepStep::Idle) {
|
||||
import_sweep_mode_ = false;
|
||||
sweep_dest_mode_ = 0;
|
||||
sweep_dest_pick_[0] = '\0';
|
||||
sweep_status_.clear();
|
||||
sweep_txid_.clear();
|
||||
sweep_dest_shown_.clear();
|
||||
}
|
||||
}
|
||||
const bool sweepRunning = (sweep_step_ == SweepStep::Running);
|
||||
// Sweep UI is only valid for a spending-key (non-view) dialog; masking by !viewMode stops preserved
|
||||
// sweep state from bleeding a "Sweep" button / progress panel into the watch-only viewing dialog.
|
||||
const bool sweepMode = import_sweep_mode_ && !viewMode;
|
||||
// Esc dismisses (this overlay has no built-in Esc handling).
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) show_import_key_ = false;
|
||||
|
||||
@@ -3020,6 +3035,46 @@ void App::renderImportKeyDialog()
|
||||
}
|
||||
ImGui::Spacing();
|
||||
|
||||
// Sweep option (spending keys only): rather than keeping the key, import it, move ALL its funds to
|
||||
// an address you own, and leave the (now-empty) key behind. A viewing key can't sign, so no sweep.
|
||||
if (!viewMode) {
|
||||
// Lock the mode + destination for the lifetime of a running sweep so the user can't desync the
|
||||
// UI from the in-flight operation (unchecking mid-run would otherwise re-enable Import).
|
||||
ImGui::BeginDisabled(sweepRunning);
|
||||
ImGui::Checkbox(TR("sweep_toggle"), &import_sweep_mode_);
|
||||
if (import_sweep_mode_) {
|
||||
ImGui::PushTextWrapPos(0.0f);
|
||||
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s", TR("sweep_caveat"));
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::Spacing();
|
||||
ImGui::TextUnformatted(TR("sweep_dest_label"));
|
||||
std::string curSel = (sweep_dest_mode_ == 1 && sweep_dest_pick_[0])
|
||||
? util::truncateMiddle(sweep_dest_pick_, 18, 8)
|
||||
: std::string(TR("sweep_dest_new"));
|
||||
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x);
|
||||
if (ImGui::BeginCombo("##sweepdest", curSel.c_str())) {
|
||||
if (ImGui::Selectable(TR("sweep_dest_new"), sweep_dest_mode_ == 0)) {
|
||||
sweep_dest_mode_ = 0;
|
||||
sweep_dest_pick_[0] = '\0';
|
||||
}
|
||||
const bool anyOwn = !state_.z_addresses.empty() || !state_.t_addresses.empty();
|
||||
if (anyOwn) ImGui::Separator();
|
||||
auto addrItem = [&](const std::string& a) {
|
||||
const bool sel = (sweep_dest_mode_ == 1 && a == sweep_dest_pick_);
|
||||
if (ImGui::Selectable(util::truncateMiddle(a, 22, 10).c_str(), sel)) {
|
||||
sweep_dest_mode_ = 1;
|
||||
snprintf(sweep_dest_pick_, sizeof(sweep_dest_pick_), "%s", a.c_str());
|
||||
}
|
||||
};
|
||||
for (const auto& a : state_.z_addresses) addrItem(a.address);
|
||||
for (const auto& a : state_.t_addresses) addrItem(a.address);
|
||||
ImGui::EndCombo();
|
||||
}
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
ImGui::Spacing();
|
||||
}
|
||||
|
||||
// Optional scan-from-height, grouped in a subtle glass panel so it reads as one distinct,
|
||||
// clearly-optional block. z_importkey / z_importviewingkey accept a start block, so a shielded
|
||||
// import of a recent key needn't rescan the whole chain. Transparent WIF (importprivkey) has no
|
||||
@@ -3079,7 +3134,33 @@ void App::renderImportKeyDialog()
|
||||
ImGui::Dummy(ImVec2(0, 2.0f * dp));
|
||||
|
||||
// Progress / result / error.
|
||||
if (import_in_progress_) {
|
||||
if (sweepMode) {
|
||||
if (sweepRunning) {
|
||||
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::Primary()), "%s%s",
|
||||
sweep_status_.c_str(), m::LoadingDots());
|
||||
} else if (sweep_step_ == SweepStep::Done) {
|
||||
if (import_key_input_[0]) sodium_memzero(import_key_input_, sizeof(import_key_input_)); // wipe on success
|
||||
ImGui::PushFont(m::Type().iconSmall());
|
||||
ImGui::TextColored(m::SuccessVec4(), ICON_MD_CHECK_CIRCLE);
|
||||
ImGui::PopFont();
|
||||
ImGui::SameLine(0, 4.0f * dp);
|
||||
ImGui::TextColored(m::SuccessVec4(), "%s", TR("sweep_done"));
|
||||
if (!sweep_dest_shown_.empty()) {
|
||||
ImGui::Spacing();
|
||||
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("sweep_to"));
|
||||
ui::widgets::AddressCopyField("##sweptto", sweep_dest_shown_);
|
||||
}
|
||||
if (!sweep_txid_.empty()) {
|
||||
ImGui::Spacing();
|
||||
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("sweep_tx"));
|
||||
ui::widgets::AddressCopyField("##sweeptx", sweep_txid_);
|
||||
}
|
||||
} else if (sweep_step_ == SweepStep::Error && !sweep_status_.empty()) {
|
||||
ImGui::PushTextWrapPos(0.0f);
|
||||
ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%s", sweep_status_.c_str());
|
||||
ImGui::PopTextWrapPos();
|
||||
}
|
||||
} else if (import_in_progress_) {
|
||||
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::Primary()), "%s%s",
|
||||
TR("import_key_rescanning"), m::LoadingDots());
|
||||
} else if (import_success_) {
|
||||
@@ -3104,32 +3185,40 @@ void App::renderImportKeyDialog()
|
||||
ImGui::Spacing();
|
||||
|
||||
float btnW = 130.0f * dp;
|
||||
bool canImport = recognized && state_.connected && !import_in_progress_;
|
||||
ImGui::BeginDisabled(!canImport);
|
||||
if (m::TactileButton(TR("import_key_import"), ImVec2(btnW, 0))) {
|
||||
import_in_progress_ = true;
|
||||
import_success_ = false;
|
||||
import_status_.clear();
|
||||
import_result_address_.clear();
|
||||
int startHeight = 0; // 0 = full rescan. Shielded imports (z_importkey/z_importviewingkey) honor
|
||||
if (import_key_scan_height_[0]) { // it; importPrivateKey ignores it for transparent WIF.
|
||||
const bool running = sweepMode ? sweepRunning : import_in_progress_;
|
||||
// Sweep needs a spending key AND a destination (a picked address when not using a fresh one).
|
||||
const bool destReady = !sweepMode || sweep_dest_mode_ == 0 || sweep_dest_pick_[0] != '\0';
|
||||
const bool canAct = recognized && state_.connected && !running && destReady;
|
||||
ImGui::BeginDisabled(!canAct);
|
||||
if (m::TactileButton(sweepMode ? TR("sweep_button") : TR("import_key_import"), ImVec2(btnW, 0))) {
|
||||
int startHeight = 0; // 0 = full rescan. Shielded imports (z_importkey) honor it; transparent
|
||||
if (import_key_scan_height_[0]) { // WIF (importprivkey) ignores it.
|
||||
startHeight = std::atoi(import_key_scan_height_);
|
||||
if (startHeight < 0) startHeight = 0;
|
||||
if (state_.sync.blocks > 0 && startHeight > state_.sync.blocks)
|
||||
startHeight = state_.sync.blocks; // never past the chain tip
|
||||
}
|
||||
importPrivateKey(std::string(import_key_input_), startHeight,
|
||||
[this](bool ok, const std::string& err, const std::string& addr) {
|
||||
import_in_progress_ = false;
|
||||
import_success_ = ok;
|
||||
if (ok) {
|
||||
import_result_address_ = addr;
|
||||
import_status_.clear();
|
||||
sodium_memzero(import_key_input_, sizeof(import_key_input_)); // wipe on success
|
||||
} else {
|
||||
import_status_ = err;
|
||||
}
|
||||
});
|
||||
if (sweepMode) {
|
||||
sweepPrivateKey(std::string(import_key_input_), startHeight, sweep_dest_mode_,
|
||||
sweep_dest_mode_ == 1 ? std::string(sweep_dest_pick_) : std::string());
|
||||
} else {
|
||||
import_in_progress_ = true;
|
||||
import_success_ = false;
|
||||
import_status_.clear();
|
||||
import_result_address_.clear();
|
||||
importPrivateKey(std::string(import_key_input_), startHeight,
|
||||
[this](bool ok, const std::string& err, const std::string& addr) {
|
||||
import_in_progress_ = false;
|
||||
import_success_ = ok;
|
||||
if (ok) {
|
||||
import_result_address_ = addr;
|
||||
import_status_.clear();
|
||||
sodium_memzero(import_key_input_, sizeof(import_key_input_)); // wipe on success
|
||||
} else {
|
||||
import_status_ = err;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
ImGui::SameLine();
|
||||
@@ -3138,12 +3227,23 @@ void App::renderImportKeyDialog()
|
||||
// Wipe the key the moment the dialog closes (Close button OR outside-click, which clears
|
||||
// show_import_key_ mid-frame). in_progress is left to the callback to clear.
|
||||
if (!show_import_key_) {
|
||||
sodium_memzero(import_key_input_, sizeof(import_key_input_));
|
||||
sodium_memzero(import_key_input_, sizeof(import_key_input_)); // key already copied into the op
|
||||
sodium_memzero(import_key_scan_height_, sizeof(import_key_scan_height_));
|
||||
import_status_.clear();
|
||||
import_result_address_.clear();
|
||||
import_success_ = false;
|
||||
import_key_reveal_ = false;
|
||||
// A running sweep is an async daemon op that keeps going in the background — preserve its UI
|
||||
// state so reopening shows the running progress and, on completion, the Done/Error panel.
|
||||
if (sweep_step_ != SweepStep::Running) {
|
||||
import_sweep_mode_ = false;
|
||||
sweep_dest_mode_ = 0;
|
||||
sweep_dest_pick_[0] = '\0';
|
||||
sweep_status_.clear();
|
||||
sweep_txid_.clear();
|
||||
sweep_dest_shown_.clear();
|
||||
sweep_step_ = SweepStep::Idle;
|
||||
}
|
||||
}
|
||||
|
||||
m::EndOverlayDialog();
|
||||
|
||||
Reference in New Issue
Block a user