From f4460beddda10dbe113756ffd1548a1945f710a0 Mon Sep 17 00:00:00 2001 From: DanS Date: Mon, 19 Oct 2026 13:10:55 -0500 Subject: [PATCH] fix(sweep,lite): don't misread a funded key as empty; guard lite send amount; use spendable balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fund-correctness audit, sweep + lite paths: - Key-import sweep: balAt() swallowed every RPC error as 0.0, so a transient timeout/warmup after the import misclassified a funded key as "no funds". Let the failure propagate to the outer handler so the user sees a real error and can retry, instead of a false __NOFUNDS__. - Lite send: the double→zatoshi conversion was an unchecked llround+cast — a negative/NaN amount would wrap to a huge uint64. Route it through util::coinsToZatoshis and reject invalid amounts before broadcast. - Lite balance: the spend gate used the gross shielded total (including unverified/unconfirmed notes). Use the backend's spendable_zbalance (a required field) for spendablePrivateBalance/Total, clamped to the shielded total. Co-Authored-By: Claude Opus 4.8 --- src/app_network.cpp | 19 +++++++++++++------ src/wallet/lite_wallet_controller.cpp | 13 ++++++++----- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/app_network.cpp b/src/app_network.cpp index 8376def..de2bd1b 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -5727,12 +5727,12 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo 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. + // Let an RPC failure PROPAGATE to the outer catch (→ a real error) instead of swallowing it as + // 0.0, which would misclassify a funded key as "__NOFUNDS__" after a transient timeout/warmup. auto balAt = [&](int minconf) -> double { - try { - nlohmann::json b = c.call("z_getbalance", {sourceAddr, minconf}); - return b.is_number() ? b.get() - : b.is_string() ? std::stod(b.get()) : 0.0; - } catch (...) { return 0.0; } + nlohmann::json b = c.call("z_getbalance", {sourceAddr, minconf}); + return b.is_number() ? b.get() + : b.is_string() ? std::stod(b.get()) : 0.0; }; double bal = balAt(1); if (bal <= 0.0) { @@ -7118,7 +7118,14 @@ void App::sendTransaction(const std::string& from, const std::string& to, wallet::LiteSendRequest req; wallet::LiteSendRecipient recipient; recipient.address = to; - recipient.amountZatoshis = static_cast(std::llround(amount * 100000000.0)); + // Guard the double→zatoshi conversion: a negative/NaN amount would wrap to a huge uint64, and a + // sub-zatoshi amount rounds to 0. Reject rather than broadcast a bogus value. + std::uint64_t zat = 0; + if (!util::coinsToZatoshis(amount, zat)) { + if (callback) callback(false, "Invalid send amount"); + return; + } + recipient.amountZatoshis = zat; recipient.memo = memo; req.recipients.push_back(std::move(recipient)); if (!lite_wallet_->sendTransaction(req)) { diff --git a/src/wallet/lite_wallet_controller.cpp b/src/wallet/lite_wallet_controller.cpp index bf4db1f..0f858bd 100644 --- a/src/wallet/lite_wallet_controller.cpp +++ b/src/wallet/lite_wallet_controller.cpp @@ -359,12 +359,15 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model, state.transparentBalance = static_cast(model.balance.transparentZatoshis) / kZatoshisPerCoin; state.totalBalance = static_cast(model.balance.totalZatoshis) / kZatoshisPerCoin; state.unconfirmedBalance = static_cast(model.balance.unconfirmedZatoshis) / kZatoshisPerCoin; - // Lite already tracks confirmed/unconfirmed itself and its per-address balances are confirmed - // (spendable outputs only). Mirror the aggregate spendable fields so full-node-shaped spend - // validation isn't zeroed on lite (the actual lite spend gate is per-address, set below). - state.spendablePrivateBalance = state.privateBalance; + // Use the backend's SPENDABLE (verified + confirmed) shielded balance for the spend gate, not the + // gross shielded total which includes unverified/unconfirmed notes you can't yet spend. The + // spendable_zbalance field is required by the parser, so it's always present when hasBalance is + // true; clamp to the shielded total defensively. Transparent is already confirmed on lite. + const double spendableShielded = static_cast( + std::min(model.balance.spendableShieldedZatoshis, model.balance.shieldedZatoshis)) / kZatoshisPerCoin; + state.spendablePrivateBalance = spendableShielded; state.spendableTransparentBalance = state.transparentBalance; - state.spendableTotalBalance = state.totalBalance; + state.spendableTotalBalance = spendableShielded + state.transparentBalance; } if (model.hasAddresses) {