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:
Binary file not shown.
@@ -1202,6 +1202,14 @@
|
||||
"submitting_transaction": "Transaktion wird übermittelt...",
|
||||
"success": "Erfolg",
|
||||
"summary": "Zusammenfassung",
|
||||
"sweep_button": "Fegen",
|
||||
"sweep_caveat": "Importiert den Schlüssel, um eine Transaktion zu signieren, die alle Mittel an Ihre Adresse verschiebt. Der Schlüssel bleibt mit leerem Guthaben in Ihrer Wallet.",
|
||||
"sweep_dest_label": "Gefegte Mittel senden an",
|
||||
"sweep_dest_new": "Neue abgeschirmte Adresse (empfohlen)",
|
||||
"sweep_done": "Fertig — Mittel an Ihre Adresse gefegt.",
|
||||
"sweep_to": "Gefegt an:",
|
||||
"sweep_toggle": "In meine Wallet fegen (Schlüssel nicht behalten)",
|
||||
"sweep_tx": "Transaktion:",
|
||||
"syncing": "Synchronisiere...",
|
||||
"t_address": "T-Adresse",
|
||||
"t_addresses": "T-Adressen",
|
||||
|
||||
@@ -1202,6 +1202,14 @@
|
||||
"submitting_transaction": "Enviando transacción...",
|
||||
"success": "Éxito",
|
||||
"summary": "Resumen",
|
||||
"sweep_button": "Barrer",
|
||||
"sweep_caveat": "Importa la clave para firmar una transacción que mueve todos sus fondos a tu dirección. La clave permanece en tu monedero con saldo cero.",
|
||||
"sweep_dest_label": "Enviar los fondos barridos a",
|
||||
"sweep_dest_new": "Nueva dirección blindada (recomendado)",
|
||||
"sweep_done": "Listo — fondos barridos a tu dirección.",
|
||||
"sweep_to": "Barrido a:",
|
||||
"sweep_toggle": "Barrer a mi monedero (no conservar la clave)",
|
||||
"sweep_tx": "Transacción:",
|
||||
"syncing": "Sincronizando...",
|
||||
"t_address": "Dirección T",
|
||||
"t_addresses": "Direcciones T",
|
||||
|
||||
@@ -1202,6 +1202,14 @@
|
||||
"submitting_transaction": "Soumission de la transaction...",
|
||||
"success": "Succès",
|
||||
"summary": "Résumé",
|
||||
"sweep_button": "Balayer",
|
||||
"sweep_caveat": "Importe la clé pour signer une transaction qui déplace tous ses fonds vers votre adresse. La clé reste dans votre portefeuille avec un solde vide.",
|
||||
"sweep_dest_label": "Envoyer les fonds balayés vers",
|
||||
"sweep_dest_new": "Nouvelle adresse blindée (recommandé)",
|
||||
"sweep_done": "Terminé — fonds balayés vers votre adresse.",
|
||||
"sweep_to": "Balayé vers :",
|
||||
"sweep_toggle": "Balayer vers mon portefeuille (ne pas conserver la clé)",
|
||||
"sweep_tx": "Transaction :",
|
||||
"syncing": "Synchronisation...",
|
||||
"t_address": "Adresse T",
|
||||
"t_addresses": "Adresses T",
|
||||
|
||||
@@ -1202,6 +1202,14 @@
|
||||
"submitting_transaction": "取引を送信中...",
|
||||
"success": "成功",
|
||||
"summary": "概要",
|
||||
"sweep_button": "集約",
|
||||
"sweep_caveat": "鍵をインポートして、すべての資金をあなたのアドレスへ移す取引に署名します。鍵は残高ゼロでウォレットに残ります。",
|
||||
"sweep_dest_label": "集約先",
|
||||
"sweep_dest_new": "新しいシールドアドレス(推奨)",
|
||||
"sweep_done": "完了 — 資金をあなたのアドレスに集約しました。",
|
||||
"sweep_to": "集約先:",
|
||||
"sweep_toggle": "ウォレットに集約(鍵は保持しない)",
|
||||
"sweep_tx": "取引:",
|
||||
"syncing": "同期中...",
|
||||
"t_address": "Tアドレス",
|
||||
"t_addresses": "Tアドレス",
|
||||
|
||||
@@ -1202,6 +1202,14 @@
|
||||
"submitting_transaction": "거래 제출 중...",
|
||||
"success": "성공",
|
||||
"summary": "요약",
|
||||
"sweep_button": "쓸어담기",
|
||||
"sweep_caveat": "키를 가져와 모든 자금을 내 주소로 옮기는 거래에 서명합니다. 키는 잔액이 0인 상태로 지갑에 남습니다.",
|
||||
"sweep_dest_label": "쓸어담은 자금을 보낼 주소",
|
||||
"sweep_dest_new": "새 차폐 주소 (권장)",
|
||||
"sweep_done": "완료 — 자금을 내 주소로 쓸어담았습니다.",
|
||||
"sweep_to": "쓸어담은 주소:",
|
||||
"sweep_toggle": "내 지갑으로 쓸어담기 (키 보관 안 함)",
|
||||
"sweep_tx": "거래:",
|
||||
"syncing": "동기화 중...",
|
||||
"t_address": "T 주소",
|
||||
"t_addresses": "T 주소",
|
||||
|
||||
@@ -1202,6 +1202,14 @@
|
||||
"submitting_transaction": "Enviando transação...",
|
||||
"success": "Sucesso",
|
||||
"summary": "Resumo",
|
||||
"sweep_button": "Varrer",
|
||||
"sweep_caveat": "Importa a chave para assinar uma transação que move todos os seus fundos para o seu endereço. A chave permanece na carteira com saldo zero.",
|
||||
"sweep_dest_label": "Enviar fundos varridos para",
|
||||
"sweep_dest_new": "Novo endereço blindado (recomendado)",
|
||||
"sweep_done": "Concluído — fundos varridos para o seu endereço.",
|
||||
"sweep_to": "Varrido para:",
|
||||
"sweep_toggle": "Varrer para minha carteira (não manter a chave)",
|
||||
"sweep_tx": "Transação:",
|
||||
"syncing": "Sincronizando...",
|
||||
"t_address": "Endereço T",
|
||||
"t_addresses": "Endereços T",
|
||||
|
||||
@@ -1202,6 +1202,14 @@
|
||||
"submitting_transaction": "Отправка транзакции...",
|
||||
"success": "Успешно",
|
||||
"summary": "Итоги",
|
||||
"sweep_button": "Перевести",
|
||||
"sweep_caveat": "Импортирует ключ, чтобы подписать одну транзакцию, переводящую все его средства на ваш адрес. Ключ остаётся в кошельке с нулевым балансом.",
|
||||
"sweep_dest_label": "Отправить переведённые средства на",
|
||||
"sweep_dest_new": "Новый экранированный адрес (рекомендуется)",
|
||||
"sweep_done": "Готово — средства переведены на ваш адрес.",
|
||||
"sweep_to": "Переведено на:",
|
||||
"sweep_toggle": "Перевести в мой кошелёк (не сохранять ключ)",
|
||||
"sweep_tx": "Транзакция:",
|
||||
"syncing": "Синхронизация...",
|
||||
"t_address": "T-адрес",
|
||||
"t_addresses": "T-адреса",
|
||||
|
||||
@@ -1202,6 +1202,14 @@
|
||||
"submitting_transaction": "正在提交交易...",
|
||||
"success": "成功",
|
||||
"summary": "摘要",
|
||||
"sweep_button": "归集",
|
||||
"sweep_caveat": "导入密钥以签署一笔将其所有资金转移到您地址的交易。密钥将保留在钱包中,余额为零。",
|
||||
"sweep_dest_label": "将归集资金发送到",
|
||||
"sweep_dest_new": "新的屏蔽地址(推荐)",
|
||||
"sweep_done": "完成 — 资金已归集到您的地址。",
|
||||
"sweep_to": "归集到:",
|
||||
"sweep_toggle": "归集到我的钱包(不保留密钥)",
|
||||
"sweep_tx": "交易:",
|
||||
"syncing": "同步中...",
|
||||
"t_address": "T 地址",
|
||||
"t_addresses": "T 地址",
|
||||
|
||||
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();
|
||||
|
||||
18
src/app.h
18
src/app.h
@@ -305,7 +305,13 @@ public:
|
||||
// startHeight > 0 rescans from that block (shielded RPCs only; ignored for transparent WIF).
|
||||
void importPrivateKey(const std::string& key, int startHeight,
|
||||
std::function<void(bool, const std::string&, const std::string&)> callback);
|
||||
|
||||
|
||||
// Sweep a spending key: import it (rescan) then z_sendmany ALL its funds (balance − fee) to a
|
||||
// destination you own — a freshly generated shielded address when destMode == 0, else destExisting.
|
||||
// Drives the sweep_step_ / sweep_status_ / sweep_txid_ state; reuses the async-operation tracker.
|
||||
void sweepPrivateKey(const std::string& key, int startHeight, int destMode,
|
||||
const std::string& destExisting);
|
||||
|
||||
// Wallet backup
|
||||
void backupWallet(const std::string& destination, std::function<void(bool, const std::string&)> callback);
|
||||
// Export the wallet's BIP39 seed phrase (z_exportmnemonic). The callback receives
|
||||
@@ -812,6 +818,16 @@ private:
|
||||
std::string import_result_address_; // address imported on success (shown as a copy field)
|
||||
bool import_view_mode_ = false; // dialog mode: false = spending key, true = viewing key
|
||||
char import_key_scan_height_[16] = {0}; // optional rescan start height (shielded spend / viewing-key imports)
|
||||
// --- Sweep: import a spending key then move all its funds to one of your own addresses, instead
|
||||
// of keeping the key in the wallet (spending-key / non-view mode only). ---
|
||||
bool import_sweep_mode_ = false; // sweep instead of a plain import
|
||||
int sweep_dest_mode_ = 0; // destination: 0 = fresh shielded address, 1 = an existing one
|
||||
char sweep_dest_pick_[128] = {0}; // chosen existing destination (sweep_dest_mode_ == 1)
|
||||
enum class SweepStep { Idle, Running, Done, Error };
|
||||
SweepStep sweep_step_ = SweepStep::Idle;
|
||||
std::string sweep_status_; // progress / error text
|
||||
std::string sweep_txid_; // sweep transaction id (on success)
|
||||
std::string sweep_dest_shown_; // the destination address the funds were swept to
|
||||
std::string backup_status_;
|
||||
bool backup_success_ = false;
|
||||
|
||||
|
||||
@@ -2983,6 +2983,161 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
|
||||
});
|
||||
}
|
||||
|
||||
// Sweep a spending key: import it (a full rescan populates its UTXOs/notes — the stock node has no
|
||||
// address index, so there is no way to enumerate them without importing) then z_sendmany ALL of the
|
||||
// key's funds (balance − fee) to a destination the user already controls. The imported key is left in
|
||||
// the wallet with an empty balance (there is no remove-key RPC) — the point is that the funds now sit
|
||||
// on the user's own key. z_sendmany (not z_mergetoaddress): it moves a single-UTXO transparent source
|
||||
// and fails loudly rather than silently leaving a remainder. Fund-moving — see the reviewed design.
|
||||
void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMode,
|
||||
const std::string& destExisting)
|
||||
{
|
||||
if (!state_.connected || !rpc_ || !worker_) {
|
||||
sweep_status_ = "Not connected to the daemon.";
|
||||
sweep_step_ = SweepStep::Error;
|
||||
return;
|
||||
}
|
||||
// Trim (manual entry doesn't go through the dialog's Paste trimmer).
|
||||
std::string key(rawKey);
|
||||
while (!key.empty() && (key.front()==' '||key.front()=='\t'||key.front()=='\n'||key.front()=='\r')) key.erase(key.begin());
|
||||
while (!key.empty() && (key.back()==' '||key.back()=='\t'||key.back()=='\n'||key.back()=='\r')) key.pop_back();
|
||||
|
||||
// Sweeping requires a SPENDING key (transparent WIF or shielded z-spending key) — a viewing key
|
||||
// can't sign, and this must never accidentally route to z_importviewingkey.
|
||||
if (!services::WalletSecurityController::isRecognizedPrivateKey(key)) {
|
||||
sweep_status_ = "Enter a spending key (a viewing key can't move funds).";
|
||||
sweep_step_ = SweepStep::Error;
|
||||
return;
|
||||
}
|
||||
|
||||
sweep_step_ = SweepStep::Running;
|
||||
sweep_status_ = "Importing key & rescanning…";
|
||||
sweep_txid_.clear();
|
||||
sweep_dest_shown_.clear();
|
||||
|
||||
const bool shielded = services::WalletSecurityController::classifyPrivateKey(key)
|
||||
== services::WalletSecurityController::KeyKind::Shielded;
|
||||
const double fee = DRAGONX_DEFAULT_FEE;
|
||||
worker_->post([this, key, startHeight, destMode, destExisting, shielded, fee]() -> rpc::RPCWorker::MainCb {
|
||||
std::string err, dest, sourceAddr, amountStr;
|
||||
double amount = 0.0;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Settings / Sweep key");
|
||||
|
||||
// For a shielded key this daemon's z_importkey returns null (no address), so snapshot the
|
||||
// z-address set before the import and diff it after to find the newly-controlled address.
|
||||
std::vector<std::string> preZ;
|
||||
bool preZok = false;
|
||||
auto listZ = [&](std::vector<std::string>& out) {
|
||||
out.clear();
|
||||
auto la = rpc_->call("z_listaddresses");
|
||||
if (la.is_array()) for (auto& a : la) if (a.is_string()) out.push_back(a.get<std::string>());
|
||||
};
|
||||
if (shielded) { try { listZ(preZ); preZok = true; } catch (...) { preZok = false; } }
|
||||
|
||||
// 1. Import the key (blocks until the rescan completes → its funds become spendable).
|
||||
std::string method;
|
||||
nlohmann::json params;
|
||||
if (shielded) { method = "z_importkey"; params = {key, "yes"}; }
|
||||
else { method = "importprivkey"; params = {key, "", true}; }
|
||||
if (startHeight > 0 && shielded) params.push_back(startHeight);
|
||||
nlohmann::json r = rpc_->call(method, params);
|
||||
|
||||
// 2. Determine the swept address. importprivkey returns the t-address string; z_importkey
|
||||
// returns null, so diff the z-address list to find the one the key just added.
|
||||
if (shielded) {
|
||||
// A failed pre-snapshot would make the diff treat pre-existing addresses as "new" and
|
||||
// could pick the WRONG source — refuse rather than risk moving another address's funds.
|
||||
if (!preZok) throw std::runtime_error("Couldn't read the wallet's addresses to identify the swept key. Try again.");
|
||||
std::vector<std::string> postZ;
|
||||
listZ(postZ);
|
||||
std::vector<std::string> added;
|
||||
for (const auto& s : postZ)
|
||||
if (std::find(preZ.begin(), preZ.end(), s) == preZ.end()) added.push_back(s);
|
||||
if (added.size() == 1) sourceAddr = added[0];
|
||||
else if (added.empty()) throw std::runtime_error("__ALREADY__"); // key already in wallet
|
||||
else throw std::runtime_error("Could not identify the key's address after import.");
|
||||
} else {
|
||||
if (r.is_string()) sourceAddr = r.get<std::string>();
|
||||
else if (r.is_object() && r.contains("address") && r["address"].is_string())
|
||||
sourceAddr = r["address"].get<std::string>();
|
||||
}
|
||||
if (sourceAddr.empty()) throw std::runtime_error("Could not determine the key's address.");
|
||||
|
||||
// 3. Confirm the key holds spendable (confirmed) funds. z_getbalance covers t- and z-addrs.
|
||||
auto balAt = [&](int minconf) -> double {
|
||||
try {
|
||||
nlohmann::json b = rpc_->call("z_getbalance", {sourceAddr, minconf});
|
||||
return b.is_number() ? b.get<double>()
|
||||
: b.is_string() ? std::stod(b.get<std::string>()) : 0.0;
|
||||
} catch (...) { return 0.0; }
|
||||
};
|
||||
double bal = balAt(1);
|
||||
if (bal <= 0.0) {
|
||||
throw std::runtime_error(balAt(0) > 0.0 ? "__UNCONFIRMED__" : "__NOFUNDS__");
|
||||
}
|
||||
if (bal <= fee) throw std::runtime_error("__DUST__");
|
||||
|
||||
// 4. Resolve the destination — a fresh shielded address by default, else the picked one.
|
||||
if (destMode == 0) dest = rpc_->call("z_getnewaddress").get<std::string>();
|
||||
else dest = destExisting;
|
||||
if (dest.empty()) throw std::runtime_error("No destination address for the sweep.");
|
||||
|
||||
// Send everything; the fee consumes the remainder (no change/residue). Compute in integer
|
||||
// satoshis so the fixed-decimal amount is exact for all realistic balances (double bal-fee
|
||||
// is exact below 2^52 sat ≈ 45M DRGX; above that the JSON-parsed balance is itself lossy —
|
||||
// a swept key that large is implausible and would fail safe with funds left in place).
|
||||
const long long feeSats = (long long)std::llround(fee * 100000000.0);
|
||||
const long long amtSats = (long long)std::llround(bal * 100000000.0) - feeSats;
|
||||
if (amtSats <= 0) throw std::runtime_error("__DUST__");
|
||||
char amtBuf[32];
|
||||
snprintf(amtBuf, sizeof(amtBuf), "%lld.%08lld",
|
||||
amtSats / 100000000LL, amtSats % 100000000LL);
|
||||
amountStr = amtBuf;
|
||||
amount = (double)amtSats / 100000000.0; // for send bookkeeping / display only
|
||||
} catch (const std::exception& e) {
|
||||
err = e.what();
|
||||
} catch (...) {
|
||||
err = "Sweep failed (unknown error)";
|
||||
}
|
||||
return [this, err, sourceAddr, dest, amount, amountStr, fee]() {
|
||||
invalidateAddressValidationCache();
|
||||
refreshAddresses();
|
||||
if (!err.empty()) {
|
||||
if (err == "__NOFUNDS__") sweep_status_ = "This key holds no funds to sweep.";
|
||||
else if (err == "__UNCONFIRMED__") sweep_status_ = "This key's funds are still unconfirmed — try again shortly.";
|
||||
else if (err == "__ALREADY__") sweep_status_ = "This key is already in your wallet — use Send to move its funds.";
|
||||
else if (err == "__DUST__") sweep_status_ = "This key's balance is too small to cover the network fee.";
|
||||
else sweep_status_ = err;
|
||||
sweep_step_ = SweepStep::Error;
|
||||
return;
|
||||
}
|
||||
sweep_dest_shown_ = dest;
|
||||
sweep_status_ = "Sweeping funds to your address…";
|
||||
// Send all funds from the swept address to the destination through the tested z_sendmany
|
||||
// wrapper (fixed-decimal amount + async-op tracking). z_sendmany needs every input in one
|
||||
// transaction to cover the full balance, so it fails loudly rather than partial-sweeping.
|
||||
nlohmann::json recipients = nlohmann::json::array();
|
||||
nlohmann::json rcp;
|
||||
rcp["address"] = dest;
|
||||
rcp["amount"] = amountStr; // exact integer-satoshi fixed-decimal string
|
||||
recipients.push_back(rcp);
|
||||
submitZSendMany(sourceAddr, dest, amount, fee, "", recipients, "Settings / Sweep key",
|
||||
/*markFeeGapRetry*/ false, [this](bool ok, const std::string& result) {
|
||||
if (ok) {
|
||||
sweep_txid_ = result;
|
||||
sweep_status_.clear();
|
||||
sweep_step_ = SweepStep::Done;
|
||||
} else {
|
||||
sweep_status_ = result.empty() ? "The sweep transaction failed." : result;
|
||||
sweep_step_ = SweepStep::Error;
|
||||
}
|
||||
refreshBalance();
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
void App::exportSeedPhrase(std::function<void(bool, bool, const std::string&, const std::string&)> callback)
|
||||
{
|
||||
if (!state_.connected || !rpc_ || !worker_) {
|
||||
|
||||
@@ -1371,6 +1371,15 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["import_scan_transparent"] = "Transparent keys always rescan fully";
|
||||
strings_["import_scan_tip"] = "current height";
|
||||
strings_["paste_clip_empty"] = "Clipboard is empty";
|
||||
// Sweep (import a spending key, then move all its funds to your own address).
|
||||
strings_["sweep_toggle"] = "Sweep to my wallet (don't keep the key)";
|
||||
strings_["sweep_caveat"] = "Imports the key to sign one transaction moving all its funds to your address. The key stays in your wallet with an empty balance.";
|
||||
strings_["sweep_dest_label"] = "Send swept funds to";
|
||||
strings_["sweep_dest_new"] = "New shielded address (recommended)";
|
||||
strings_["sweep_button"] = "Sweep";
|
||||
strings_["sweep_done"] = "Done \xE2\x80\x94 funds swept to your address.";
|
||||
strings_["sweep_to"] = "Swept to:";
|
||||
strings_["sweep_tx"] = "Transaction:";
|
||||
|
||||
// --- Key Export Dialog ---
|
||||
strings_["key_export_fetching"] = "Fetching key from wallet...";
|
||||
|
||||
Reference in New Issue
Block a user