diff --git a/res/fonts/NotoSansCJK-Subset.ttf b/res/fonts/NotoSansCJK-Subset.ttf index b157a61..d9c0cfa 100644 Binary files a/res/fonts/NotoSansCJK-Subset.ttf and b/res/fonts/NotoSansCJK-Subset.ttf differ diff --git a/res/lang/de.json b/res/lang/de.json index e22a357..b91f739 100644 --- a/res/lang/de.json +++ b/res/lang/de.json @@ -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", diff --git a/res/lang/es.json b/res/lang/es.json index 5f8d835..9ae6c20 100644 --- a/res/lang/es.json +++ b/res/lang/es.json @@ -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", diff --git a/res/lang/fr.json b/res/lang/fr.json index 9bc3523..06017f0 100644 --- a/res/lang/fr.json +++ b/res/lang/fr.json @@ -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", diff --git a/res/lang/ja.json b/res/lang/ja.json index fb13e75..016bc39 100644 --- a/res/lang/ja.json +++ b/res/lang/ja.json @@ -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アドレス", diff --git a/res/lang/ko.json b/res/lang/ko.json index 6634750..d543e76 100644 --- a/res/lang/ko.json +++ b/res/lang/ko.json @@ -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 주소", diff --git a/res/lang/pt.json b/res/lang/pt.json index 93522f7..b094d16 100644 --- a/res/lang/pt.json +++ b/res/lang/pt.json @@ -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", diff --git a/res/lang/ru.json b/res/lang/ru.json index 234d4b8..1e75716 100644 --- a/res/lang/ru.json +++ b/res/lang/ru.json @@ -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-адреса", diff --git a/res/lang/zh.json b/res/lang/zh.json index 8a465d8..4f34c0d 100644 --- a/res/lang/zh.json +++ b/res/lang/zh.json @@ -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 地址", diff --git a/src/app.cpp b/src/app.cpp index 34125c8..fb34df0 100644 --- a/src/app.cpp +++ b/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(); diff --git a/src/app.h b/src/app.h index a2745a8..47f8e15 100644 --- a/src/app.h +++ b/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 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 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; diff --git a/src/app_network.cpp b/src/app_network.cpp index 756332c..e9e2b70 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -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 preZ; + bool preZok = false; + auto listZ = [&](std::vector& 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()); + }; + 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 postZ; + listZ(postZ); + std::vector 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(); + else if (r.is_object() && r.contains("address") && r["address"].is_string()) + sourceAddr = r["address"].get(); + } + 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() + : b.is_string() ? std::stod(b.get()) : 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(); + 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 callback) { if (!state_.connected || !rpc_ || !worker_) { diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 398de9a..af2e00b 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -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...";