diff --git a/src/app.h b/src/app.h index 6c75bee..e5c0a43 100644 --- a/src/app.h +++ b/src/app.h @@ -12,6 +12,7 @@ #include #include #include +#include #include "data/transaction_history_cache.h" #include "data/wallet_state.h" #include "rpc/connection.h" @@ -448,6 +449,11 @@ private: bool sendStopCommandSafely(rpc::RPCClient& client, const char* context); void maybeFinishTransactionSendProgress(); + // Shared body of createNewZAddress/createNewTAddress (which are thin public forwarders). + // `shielded` selects the z_getnewaddress/getnewaddress RPC, the "shielded"/"transparent" type + // string, and the z_addresses/t_addresses target (the new AddressInfo is pushed into both that + // list and state_.addresses). Lite builds derive locally via the controller and early-return. + void createNewAddress(bool shielded, std::function callback); void upsertPendingSendTransaction(const std::string& opid, const std::string& from, const std::string& to, @@ -464,10 +470,28 @@ private: void resendWithFeeGapWorkaround(const std::string& from, const std::string& to, double amount, double fee, const std::string& memo, std::function callback); + // Shared z_sendmany submit path for sendTransaction (single recipient, markFeeGapRetry=false) + // and resendWithFeeGapWorkaround (recipient + self-output, markFeeGapRetry=true). Owns the + // in-flight increment, the worker post + call, and the result closure (dirty flags, opid + // tracking, pending-send bookkeeping, callback delivery). Callers build `recipients` (and pass + // the recipient `to`/`amount`/`memo`/`fee` used to record the optimistic pending-send row). + // When markFeeGapRetry is set, the returned opid is recorded in send_feegap_retried_opids_ so a + // retry of a retry is reported as a real error. + void submitZSendMany(const std::string& from, const std::string& to, double amount, double fee, + const std::string& memo, const nlohmann::json& recipients, + const char* traceLabel, bool markFeeGapRetry, + std::function callback); void markPendingSendTransactionSucceeded(const std::string& opid, const std::string& txid); void removePendingSendTransactions(const std::vector& opids, bool restoreBalances); + // Apply a signed per-address balance delta for a pending send: walk z_addresses then + // t_addresses for `fromAddress` and clamp its balance at >=0. A positive `signedAmount` + // restores a debit; a negative one applies it. When `includeAggregates` is set, adjust the + // private/transparent bucket (chosen by the address's leading 'z') and totalBalance with the + // same clamp. Shared by the three pending-send delta sites so they can't drift. + void applyPendingSendDelta(const std::string& fromAddress, double signedAmount, + bool includeAggregates); // Deliver a deferred z_sendmany result to its waiting UI callback once the opid // reaches a terminal status. Returns true if a callback was registered (and fired). bool invokeSendResultCallback(const std::string& opid, bool ok, diff --git a/src/app_network.cpp b/src/app_network.cpp index d560125..c4a0f17 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -800,22 +800,7 @@ void App::upsertPendingSendTransaction(const std::string& opid, state_.transactions.insert(state_.transactions.begin(), std::move(pending)); } if (newPending) { - auto applyDelta = [&](std::vector& addresses) { - for (auto& address : addresses) { - if (address.address == pendingInfo.from) { - address.balance = std::max(0.0, address.balance - pendingInfo.amount); - return true; - } - } - return false; - }; - if (!applyDelta(state_.z_addresses)) applyDelta(state_.t_addresses); - if (!pendingInfo.from.empty() && pendingInfo.from[0] == 'z') { - state_.privateBalance = std::max(0.0, state_.privateBalance - pendingInfo.amount); - } else { - state_.transparentBalance = std::max(0.0, state_.transparentBalance - pendingInfo.amount); - } - state_.totalBalance = std::max(0.0, state_.totalBalance - pendingInfo.amount); + applyPendingSendDelta(pendingInfo.from, -pendingInfo.amount, /*includeAggregates*/ true); } state_.last_tx_update = std::time(nullptr); } @@ -850,22 +835,8 @@ void App::removePendingSendTransactions(const std::vector& opids, for (const auto& opid : opidSet) { auto pending = pending_send_info_.find(opid); if (pending == pending_send_info_.end()) continue; - auto restoreBalance = [&](std::vector& addresses) { - for (auto& address : addresses) { - if (address.address == pending->second.from) { - address.balance += pending->second.amount; - return true; - } - } - return false; - }; - if (!restoreBalance(state_.z_addresses)) restoreBalance(state_.t_addresses); - if (!pending->second.from.empty() && pending->second.from[0] == 'z') { - state_.privateBalance += pending->second.amount; - } else { - state_.transparentBalance += pending->second.amount; - } - state_.totalBalance += pending->second.amount; + applyPendingSendDelta(pending->second.from, pending->second.amount, + /*includeAggregates*/ true); } } state_.transactions.erase( @@ -878,6 +849,32 @@ void App::removePendingSendTransactions(const std::vector& opids, state_.last_tx_update = std::time(nullptr); } +void App::applyPendingSendDelta(const std::string& fromAddress, double signedAmount, + bool includeAggregates) +{ + // For a debit (signedAmount < 0) this clamps at >=0 exactly as the debit sites did. For a + // restore (signedAmount > 0) the clamp is a no-op — max(0, bal+amt) == bal+amt when bal,amt>=0 — + // so the single clamped form reproduces the original restore's unclamped bal += amt. + auto applyToAddress = [&](std::vector& addresses) { + for (auto& address : addresses) { + if (address.address == fromAddress) { + address.balance = std::max(0.0, address.balance + signedAmount); + return true; + } + } + return false; + }; + if (!applyToAddress(state_.z_addresses)) applyToAddress(state_.t_addresses); + if (includeAggregates) { + if (!fromAddress.empty() && fromAddress[0] == 'z') { + state_.privateBalance = std::max(0.0, state_.privateBalance + signedAmount); + } else { + state_.transparentBalance = std::max(0.0, state_.transparentBalance + signedAmount); + } + state_.totalBalance = std::max(0.0, state_.totalBalance + signedAmount); + } +} + void App::trackOperation(const std::string& opid) { if (opid.empty()) return; @@ -903,24 +900,7 @@ void App::applyPendingSendBalanceDeltas(bool includeAggregateBalances) { for (const auto& [opid, pending] : pending_send_info_) { (void)opid; - auto applyDelta = [&](std::vector& addresses) { - for (auto& address : addresses) { - if (address.address == pending.from) { - address.balance = std::max(0.0, address.balance - pending.amount); - return true; - } - } - return false; - }; - if (!applyDelta(state_.z_addresses)) applyDelta(state_.t_addresses); - if (includeAggregateBalances) { - if (!pending.from.empty() && pending.from[0] == 'z') { - state_.privateBalance = std::max(0.0, state_.privateBalance - pending.amount); - } else { - state_.transparentBalance = std::max(0.0, state_.transparentBalance - pending.amount); - } - state_.totalBalance = std::max(0.0, state_.totalBalance - pending.amount); - } + applyPendingSendDelta(pending.from, -pending.amount, includeAggregateBalances); } } @@ -1953,64 +1933,29 @@ void App::applyDefaultBanlist() void App::createNewZAddress(std::function callback) { - // Lite build: derive locally via the controller (fast, no network). The backend auto-saves - // new addresses; the next lite refresh lists it with a balance. - if (lite_wallet_) { - const auto result = lite_wallet_->newAddress(/*shielded*/ true); - if (result.ok) { - AddressInfo info; - info.address = result.address; - info.type = "shielded"; - info.balance = 0.0; - state_.z_addresses.push_back(info); - state_.addresses.push_back(info); - address_list_dirty_ = true; - } - if (callback) callback(result.ok ? result.address : std::string()); - return; - } - - if (!state_.connected || !rpc_ || !worker_) return; - - worker_->post([this, callback]() -> rpc::RPCWorker::MainCb { - std::string addr; - try { - rpc::RPCClient::TraceScope trace("Receive tab / New shielded address"); - json result = rpc_->call("z_getnewaddress"); - addr = result.get(); - } catch (const std::exception& e) { - DEBUG_LOGF("z_getnewaddress error: %s\n", e.what()); - } - return [this, callback, addr]() { - if (!addr.empty()) { - // Inject immediately so UI can select the address next frame - AddressInfo info; - info.address = addr; - info.type = "shielded"; - info.balance = 0.0; - state_.z_addresses.push_back(info); - state_.addresses.push_back(info); // keep combined view in sync immediately - address_list_dirty_ = true; - // Also trigger full refresh to get proper balances - addresses_dirty_ = true; - refreshAddresses(); - } - if (callback) callback(addr); - }; - }); + createNewAddress(/*shielded*/ true, std::move(callback)); } void App::createNewTAddress(std::function callback) { - // Lite build: derive locally via the controller (see createNewZAddress). + createNewAddress(/*shielded*/ false, std::move(callback)); +} + +void App::createNewAddress(bool shielded, std::function callback) +{ + const char* typeStr = shielded ? "shielded" : "transparent"; + auto& targetList = shielded ? state_.z_addresses : state_.t_addresses; + + // Lite build: derive locally via the controller (fast, no network). The backend auto-saves + // new addresses; the next lite refresh lists it with a balance. if (lite_wallet_) { - const auto result = lite_wallet_->newAddress(/*shielded*/ false); + const auto result = lite_wallet_->newAddress(shielded); if (result.ok) { AddressInfo info; info.address = result.address; - info.type = "transparent"; + info.type = typeStr; info.balance = 0.0; - state_.t_addresses.push_back(info); + targetList.push_back(info); state_.addresses.push_back(info); address_list_dirty_ = true; } @@ -2020,23 +1965,26 @@ void App::createNewTAddress(std::function callback) if (!state_.connected || !rpc_ || !worker_) return; - worker_->post([this, callback]() -> rpc::RPCWorker::MainCb { + const char* rpcMethod = shielded ? "z_getnewaddress" : "getnewaddress"; + const char* traceLabel = shielded ? "Receive tab / New shielded address" + : "Receive tab / New transparent address"; + worker_->post([this, callback, shielded, typeStr, rpcMethod, traceLabel]() -> rpc::RPCWorker::MainCb { std::string addr; try { - rpc::RPCClient::TraceScope trace("Receive tab / New transparent address"); - json result = rpc_->call("getnewaddress"); + rpc::RPCClient::TraceScope trace(traceLabel); + json result = rpc_->call(rpcMethod); addr = result.get(); } catch (const std::exception& e) { - DEBUG_LOGF("getnewaddress error: %s\n", e.what()); + DEBUG_LOGF("%s error: %s\n", rpcMethod, e.what()); } - return [this, callback, addr]() { + return [this, callback, addr, shielded, typeStr]() { if (!addr.empty()) { // Inject immediately so UI can select the address next frame AddressInfo info; info.address = addr; - info.type = "transparent"; + info.type = typeStr; info.balance = 0.0; - state_.t_addresses.push_back(info); + (shielded ? state_.z_addresses : state_.t_addresses).push_back(info); state_.addresses.push_back(info); // keep combined view in sync immediately address_list_dirty_ = true; // Also trigger full refresh to get proper balances @@ -2414,26 +2362,36 @@ void App::sendTransaction(const std::string& from, const std::string& to, return; } - send_progress_active_ = true; - ++send_submissions_in_flight_; // z_sendmany signature is (fromaddress, amounts, minconf, fee). The fee MUST be a JSON number: // the daemon reads it with get_real(), so a string is rejected ("JSON value is not a number as // expected"). get_real() parses the double directly and accepts any number notation (incl. the // "5e-05" form of a small fee), so passing the raw double is correct for every fee value. // (The recipient "amount" above is intentionally a fixed-decimal STRING — that field is parsed // with ParseFixedPoint, which a scientific-notation double would break.) - worker_->post([this, from, to, amount, fee, memo, recipients, callback]() -> rpc::RPCWorker::MainCb { + submitZSendMany(from, to, amount, fee, memo, recipients, "Send tab / Submit transaction", + /*markFeeGapRetry*/ false, std::move(callback)); +} + +void App::submitZSendMany(const std::string& from, const std::string& to, double amount, double fee, + const std::string& memo, const nlohmann::json& recipients, + const char* traceLabel, bool markFeeGapRetry, + std::function callback) +{ + send_progress_active_ = true; + ++send_submissions_in_flight_; + worker_->post([this, from, to, amount, fee, memo, recipients, callback, traceLabel, + markFeeGapRetry]() -> rpc::RPCWorker::MainCb { bool ok = false; std::string result_str; try { - rpc::RPCClient::TraceScope trace("Send tab / Submit transaction"); + rpc::RPCClient::TraceScope trace(traceLabel); auto result = rpc_->call("z_sendmany", {from, recipients, 1, fee}); result_str = result.get(); ok = true; } catch (const std::exception& e) { result_str = e.what(); } - return [this, callback, ok, result_str, from, to, amount, fee, memo]() { + return [this, callback, ok, result_str, from, to, amount, fee, memo, markFeeGapRetry]() { if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_; if (ok) { // A send changes address balances — refresh on next cycle @@ -2448,6 +2406,8 @@ void App::sendTransaction(const std::string& from, const std::string& to, // "sent successfully" for an operation that may still fail. if (!result_str.empty()) { pending_opids_.push_back(result_str); + if (markFeeGapRetry) + send_feegap_retried_opids_.insert(result_str); // a retry of a retry is a real error upsertPendingSendTransaction(result_str, from, to, amount, memo, fee); if (callback) pending_send_callbacks_[result_str] = callback; } else if (callback) { @@ -2537,40 +2497,8 @@ void App::resendWithFeeGapWorkaround(const std::string& from, const std::string& selfOut["amount"] = util::formatAmountFixed(fee); recipients.push_back(selfOut); - send_progress_active_ = true; - ++send_submissions_in_flight_; - worker_->post([this, from, to, amount, fee, memo, recipients, callback]() -> rpc::RPCWorker::MainCb { - bool ok = false; - std::string result_str; - try { - rpc::RPCClient::TraceScope trace("Send tab / Fee-gap retry"); - auto result = rpc_->call("z_sendmany", {from, recipients, 1, fee}); - result_str = result.get(); - ok = true; - } catch (const std::exception& e) { - result_str = e.what(); - } - return [this, callback, ok, result_str, from, to, amount, fee, memo]() { - if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_; - if (ok) { - addresses_dirty_ = true; - transactions_dirty_ = true; - last_tx_block_height_ = -1; - network_refresh_.markWalletMutationRefresh(); - if (!result_str.empty()) { - pending_opids_.push_back(result_str); - send_feegap_retried_opids_.insert(result_str); // a retry of a retry is a real error - upsertPendingSendTransaction(result_str, from, to, amount, memo, fee); - if (callback) pending_send_callbacks_[result_str] = callback; - } else if (callback) { - callback(true, result_str); - } - } else { - send_progress_active_ = false; - if (callback) callback(false, result_str); - } - }; - }); + submitZSendMany(from, to, amount, fee, memo, recipients, "Send tab / Fee-gap retry", + /*markFeeGapRetry*/ true, std::move(callback)); } // --------------------------------------------------------------------------------------------