From 6c2a2777514b324ef2337e22fa2765b3ee017f44 Mon Sep 17 00:00:00 2001 From: DanS Date: Mon, 19 Oct 2026 19:47:38 -0500 Subject: [PATCH] fix(wallet): surface the real z_getnewaddress error; sweep coinbase in migrate-to-seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a legacy-wallet migration support case (mined T-funds, 3y-old Silent Dragon wallet.dat): the balance shows but new-address and migrate both fail with unhelpful/silent results. Two fixes: 1) Real address-creation error. createNewAddress() swallowed the daemon's z_getnewaddress error and the UI showed a generic "Failed to create shielded address". It now keeps the daemon's reason (friendly-mapped via friendlyRpcError) in last_address_error_, which the Receive tab and request-payment dialog show instead of the generic string — so a legacy/degraded wallet sees the actual "HD seed not found". And when that specific error appears it flips wallet_degraded_ + warns once, even for an EXTERNAL daemon (detectWalletDegraded() only scans the embedded daemon's log and would otherwise miss it). 2) Migrate-to-seed sweep skipped coinbase. beginSweepToSeedWallet()'s z_mergetoaddress ignores coinbase UTXOs, so a miner's coinbase-only balance produced "Could not find any funds to merge", the legacy balance never reached ~0, and the adopt gate stalled silently. The sweep now falls back to z_shieldcoinbase("*", dest, fee, 50) to the SAME shielded destination when the merge reports no funds. z_shieldcoinbase returns an opid just like the merge, so the single-opid adopt gate (opid -> txid -> confirm -> balance ~0 -> adopt) is unchanged, and a batch limit / mixed wallet drains via the existing "Sweep remaining" re-sweep. Fund-path: the z_mergetoaddress call and its args are byte-identical; the fallback only ADDS a z_shieldcoinbase to the same dest when merge finds nothing, and nothing is adopted until the swept funds confirm and the legacy balance is ~0 (gate unchanged). Runtime-verified behavior (clean build + ctest; the RPC paths are not pure-testable). Co-Authored-By: Claude Opus 4.8 --- src/app.h | 4 +++ src/app_network.cpp | 43 +++++++++++++++++++++-- src/ui/windows/receive_tab.cpp | 10 +++--- src/ui/windows/request_payment_dialog.cpp | 5 +-- 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/app.h b/src/app.h index f8a7942..fa04a4a 100644 --- a/src/app.h +++ b/src/app.h @@ -453,6 +453,9 @@ public: // Address operations void createNewZAddress(std::function callback = nullptr); void createNewTAddress(std::function callback = nullptr); + // The daemon's actual reason the last new-address RPC failed (friendly-mapped), or "" on success. Lets + // the UI show e.g. "HD seed not found" (legacy/degraded wallet) instead of a generic "couldn't create". + const std::string& lastAddressError() const { return last_address_error_; } // Hide/unhide addresses from the address list (persisted in settings) void hideAddress(const std::string& addr); @@ -1350,6 +1353,7 @@ private: // so a possibly-incomplete salvaged wallet isn't mistaken for fund loss. Warned once per session. bool wallet_auto_recovered_ = false; // a salvage happened this session bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session + std::string last_address_error_; // daemon reason the last z_getnewaddress/getnewaddress failed ("" = ok) bool wallet_degraded_ = false; // v1.3.0+ opened the wallet in DEGRADED mode (no new HD keys) bool wallet_degraded_warned_ = false; // guard: surface the degraded-mode notice once per session bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog diff --git a/src/app_network.cpp b/src/app_network.cpp index d3b957b..c013dd2 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -3957,15 +3957,32 @@ void App::createNewAddress(bool shielded, std::functionpost([this, callback, shielded, typeStr, rpcMethod, traceLabel]() -> rpc::RPCWorker::MainCb { - std::string addr; + std::string addr, genErr; try { rpc::RPCClient::TraceScope trace(traceLabel); json result = rpc_->call(rpcMethod); addr = result.get(); } catch (const std::exception& e) { DEBUG_LOGF("%s error: %s\n", rpcMethod, e.what()); + genErr = e.what(); // keep the real reason so the UI can show it instead of a generic toast } - return [this, callback, addr, shielded, typeStr]() { + return [this, callback, addr, genErr, shielded, typeStr]() { + if (addr.empty()) { + // Surface the daemon's actual reason (friendly-mapped) rather than a generic "couldn't create". + last_address_error_ = genErr.empty() ? std::string() : util::friendlyRpcError(genErr); + // "HD seed not found" means a legacy / degraded wallet can't derive new HD keys. Flip degraded + // mode here so the UI explains it even for an EXTERNAL daemon — detectWalletDegraded() only + // reads the embedded daemon's startup log and would otherwise miss this. + if (shielded && genErr.find("HD seed not found") != std::string::npos) { + wallet_degraded_ = true; + if (!wallet_degraded_warned_) { + wallet_degraded_warned_ = true; + ui::Notifications::instance().warning(TR("wallet_degraded_notify"), 30.0f); + } + } + } else { + last_address_error_.clear(); + } if (!addr.empty()) { // Inject immediately so UI can select the address next frame AddressInfo info; @@ -6489,7 +6506,27 @@ void App::beginSweepToSeedWallet() auto res = rpc_->call("z_mergetoaddress", {fromAddrs, dest, DRAGONX_DEFAULT_FEE, 0, 0}); opid = res.value("opid", std::string()); if (opid.empty()) err = "The daemon returned no operation id for the sweep."; - } catch (const std::exception& e) { err = e.what(); } + } catch (const std::exception& e) { + // z_mergetoaddress deliberately ignores coinbase UTXOs, so a miner whose legacy balance is all + // mined coinbase gets "Could not find any funds to merge". Fall back to z_shieldcoinbase, which + // DOES move coinbase, to the same shielded destination. It returns an opid exactly like the merge, + // so the adopt gate (one opid -> txid -> confirm -> balance ~0 -> adopt) is unchanged; a batch + // limit or a mixed coinbase+other wallet is drained by the existing "Sweep remaining" re-sweep. + const std::string mergeErr = e.what(); + if (mergeErr.find("find any funds to merge") != std::string::npos) { + try { + // Fixed-decimal fee string (ParseFixedPoint rejects a double that serializes as "5e-05"). + const std::string feeStr = util::formatAmountFixed(DRAGONX_DEFAULT_FEE); + auto sres = rpc_->call("z_shieldcoinbase", {std::string("*"), dest, feeStr, 50}); + opid = sres.value("opid", std::string()); + if (opid.empty()) err = "The daemon returned no operation id for the coinbase shield."; + } catch (const std::exception& e2) { + err = e2.what(); // e.g. "Could not find any coinbase funds to shield" = genuinely empty + } + } else { + err = mergeErr; + } + } return [this, opid, err]() { if (!err.empty() || opid.empty()) { // Submit failed — nothing was broadcast, so drop the "sweep in flight" marker. diff --git a/src/ui/windows/receive_tab.cpp b/src/ui/windows/receive_tab.cpp index 86f77cd..d945961 100644 --- a/src/ui/windows/receive_tab.cpp +++ b/src/ui/windows/receive_tab.cpp @@ -333,10 +333,11 @@ static void RenderAddressDropdown(App* app, float width) { } else { s_generating_address = true; if (s_addr_type_filter != 2) { - app->createNewZAddress([](const std::string& addr) { + app->createNewZAddress([app](const std::string& addr) { s_generating_address = false; if (addr.empty()) - Notifications::instance().error(TR("failed_create_shielded")); + Notifications::instance().error(app->lastAddressError().empty() + ? std::string(TR("failed_create_shielded")) : app->lastAddressError()); else { s_pending_select_address = addr; Notifications::instance().success(TR("new_shielded_created")); @@ -400,10 +401,11 @@ static void RenderMiningAddressNotice(App* app, const WalletState& state, float s_cached_qr_data.clear(); } else { s_generating_address = true; // no shielded address yet: make one, as "New" does - app->createNewZAddress([](const std::string& addr) { + app->createNewZAddress([app](const std::string& addr) { s_generating_address = false; if (addr.empty()) - Notifications::instance().error(TR("failed_create_shielded")); + Notifications::instance().error(app->lastAddressError().empty() + ? std::string(TR("failed_create_shielded")) : app->lastAddressError()); else { s_pending_select_address = addr; Notifications::instance().success(TR("new_shielded_created")); diff --git a/src/ui/windows/request_payment_dialog.cpp b/src/ui/windows/request_payment_dialog.cpp index 2d4ccb5..1344a1b 100644 --- a/src/ui/windows/request_payment_dialog.cpp +++ b/src/ui/windows/request_payment_dialog.cpp @@ -119,10 +119,11 @@ void RequestPaymentDialog::render(App* app) if (material::TactileButton(TR("request_create_shielded"), ImVec2(0, 0), S.resolveFont(actionBtn.font))) { if (app->isConnected()) { s_creating_address = true; - app->createNewZAddress([](const std::string& addr) { + app->createNewZAddress([app](const std::string& addr) { s_creating_address = false; if (addr.empty()) { - Notifications::instance().error(TR("failed_create_shielded")); + Notifications::instance().error(app->lastAddressError().empty() + ? std::string(TR("failed_create_shielded")) : app->lastAddressError()); } else { Notifications::instance().success(TR("new_shielded_created")); // Pre-select the new address if the dialog is still open and none chosen.