From d4339be94b3cc85dd0729a8412eb94a85a3623ce Mon Sep 17 00:00:00 2001 From: DanS Date: Mon, 12 Oct 2026 21:42:23 -0500 Subject: [PATCH] fix(ui): keep a chosen send-from address from auto-select overwrite The Send tab auto-selected the highest-balance address on first load. When another tab had already pre-filled the source with "Send from this address", that first-load pick overwrote the user's choice, and the dropdown's selected index could drift out of step with the address that would actually be debited when the address list changed. - The send-from string is now the single source of truth. The one-shot auto-pick only fills an empty source and never replaces one the user or another tab set, and the list index is re-derived from the string each frame so it cannot desync. - An explicit "Send from this address" choice latches the auto-pick off, so the first visit cannot undo it. - A chosen source the dropdown does not list (zero balance, view-only, or the list still loading) now previews the address that will be debited instead of the "Select a source" placeholder. User-visible: a send-from address chosen from another tab survives the first load and later refresh ticks. Tests: the source resolver keeps a pre-fill on first visit, fills an empty source with the best spendable address, keeps a source missing from a short list with index -1, latches nothing while disconnected or with an empty list, and re-derives a stale index from the string. Co-Authored-By: Claude Opus 4.8 --- src/data/wallet_state.cpp | 26 ++++++++++++++++++++++++++ src/data/wallet_state.h | 14 ++++++++++++++ src/ui/windows/send_tab.cpp | 25 ++++++++++++++++--------- tests/test_phase4.cpp | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 9 deletions(-) diff --git a/src/data/wallet_state.cpp b/src/data/wallet_state.cpp index 2767c01..aa24ea4 100644 --- a/src/data/wallet_state.cpp +++ b/src/data/wallet_state.cpp @@ -43,6 +43,32 @@ int bestSpendableAddressIndex(const std::vector& addresses) return bestIndex; } +int addressIndexByString(const std::vector& addresses, const std::string& address) +{ + if (address.empty()) return -1; + for (size_t i = 0; i < addresses.size(); ++i) { + if (addresses[i].address == address) return static_cast(i); + } + return -1; +} + +SourceSelection resolveSourceSelection(const std::vector& addresses, const std::string& current, + bool autoLatched, bool connected) +{ + SourceSelection r; + r.address = current; + r.autoLatched = autoLatched; + r.index = addressIndexByString(addresses, current); + if (!autoLatched && connected && !addresses.empty()) { + if (current.empty()) { // only ever fill an empty source + const int best = bestSpendableAddressIndex(addresses); + if (best >= 0) { r.address = addresses[best].address; r.index = best; } + } + r.autoLatched = true; // one shot, whether or not it picked + } + return r; +} + std::string TransactionInfo::getTimeString() const { if (timestamp == 0) return "Unknown"; diff --git a/src/data/wallet_state.h b/src/data/wallet_state.h index 6e807f2..79f20d9 100644 --- a/src/data/wallet_state.h +++ b/src/data/wallet_state.h @@ -42,6 +42,20 @@ struct AddressInfo { std::vector sortedSpendableAddressIndices(const std::vector& addresses, bool requirePositiveBalance = true); int bestSpendableAddressIndex(const std::vector& addresses); +// Index of the address whose string equals `address` (exact match), or -1 (empty string / not listed). +int addressIndexByString(const std::vector& addresses, const std::string& address); + +// The Send tab's source-address resolution, pure. `current` (s_from_address) is the single source of truth: +// the one-shot auto-pick (highest confirmed balance) only fills an EMPTY source, and never replaces one the +// user - or another tab's "Send from this address" - already set. A source that is not in the list (the list +// can be transiently short) is kept as is with index -1; it must NOT trigger a re-pick. +struct SourceSelection { + std::string address; // what s_from_address must hold afterwards + int index = -1; // its row in `addresses`, -1 if not listed + bool autoLatched = false; // new value of the one-shot latch +}; +SourceSelection resolveSourceSelection(const std::vector& addresses, const std::string& current, + bool autoLatched, bool connected); /** * @brief Represents a wallet transaction diff --git a/src/ui/windows/send_tab.cpp b/src/ui/windows/send_tab.cpp index f2c0a1b..4df410e 100644 --- a/src/ui/windows/send_tab.cpp +++ b/src/ui/windows/send_tab.cpp @@ -246,15 +246,16 @@ static void RenderSourceDropdown(App* app, float width) { Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("send_sending_from")); ImGui::Dummy(ImVec2(0, Layout::spacingSm())); - // Auto-select the address with the largest balance on first load - if (!s_auto_selected && app->isConnected() && !state.addresses.empty()) { - int bestIdx = bestSpendableAddressIndex(state.addresses); - if (bestIdx >= 0) { - s_selected_from_idx = bestIdx; - snprintf(s_from_address, sizeof(s_from_address), "%s", - state.addresses[bestIdx].address.c_str()); - } - s_auto_selected = true; + // s_from_address is the single source of truth (it is what gets debited). Auto-select the address with the + // largest balance on first load ONLY when no source is set yet - never over a "Send from this address" + // pre-fill from another tab - and re-derive the list index from the string every frame so it can't desync + // when the address list changes. + { + const auto sel = resolveSourceSelection(state.addresses, s_from_address, s_auto_selected, app->isConnected()); + if (sel.address != s_from_address) + snprintf(s_from_address, sizeof(s_from_address), "%s", sel.address.c_str()); + s_auto_selected = sel.autoLatched; + s_selected_from_idx = sel.index; } // Build preview string for selected address @@ -271,6 +272,11 @@ static void RenderSourceDropdown(App* app, float width) { tag, trunc.c_str(), FormatSourceBalance(addr.balance, addr.spendableBalance).c_str()); s_source_preview = buf; + } else if (s_from_address[0] != '\0') { + // A pre-filled source that the dropdown list doesn't carry (zero-balance / view-only / list still + // loading): show the address that WILL be debited rather than the "Select a source" placeholder. + s_source_preview = util::truncateMiddle(s_from_address, + static_cast(std::max(S.drawElement("tabs.send", "addr-preview-trunc-min").size, width / S.drawElement("tabs.send", "addr-preview-trunc-divisor").size))); } else { s_source_preview = TR("send_select_source"); } @@ -1696,6 +1702,7 @@ void SetSendFromAddress(const std::string& address) strncpy(s_from_address, address.c_str(), sizeof(s_from_address) - 1); s_from_address[sizeof(s_from_address) - 1] = '\0'; s_selected_from_idx = -1; + s_auto_selected = true; // an explicit choice: the first-visit auto-pick must never replace it } } // namespace ui diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 8044bca..1060cdf 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -1131,6 +1131,39 @@ void testSpendableFiltering() auto includeZero = dragonx::sortedSpendableAddressIndices(addresses, false); EXPECT_EQ(includeZero.size(), static_cast(3)); + + using dragonx::resolveSourceSelection; + EXPECT_EQ(dragonx::addressIndexByString(addresses, "R-high"), 3); + EXPECT_EQ(dragonx::addressIndexByString(addresses, "nope"), -1); + EXPECT_EQ(dragonx::addressIndexByString(addresses, ""), -1); + { // a "Send from this address" pre-fill on the first visit must survive the auto-pick + auto r = resolveSourceSelection(addresses, "zs-low", /*latched=*/false, /*connected=*/true); + EXPECT_EQ(r.address, std::string("zs-low")); + EXPECT_EQ(r.index, 1); + EXPECT_TRUE(r.autoLatched); + } + { // empty source -> best spendable (R-high), latched + auto r = resolveSourceSelection(addresses, "", false, true); + EXPECT_EQ(r.address, std::string("R-high")); + EXPECT_EQ(r.index, 3); + EXPECT_TRUE(r.autoLatched); + } + { // a pre-fill that is not in the (short) list is kept, index -1, no re-pick + auto r = resolveSourceSelection(addresses, "zs-gone", false, true); + EXPECT_EQ(r.address, std::string("zs-gone")); + EXPECT_EQ(r.index, -1); + EXPECT_TRUE(r.autoLatched); + } + { // not connected / empty list: nothing latches, pre-fill kept + EXPECT_FALSE(resolveSourceSelection(addresses, "zs-low", false, false).autoLatched); + EXPECT_FALSE(resolveSourceSelection({}, "zs-low", false, true).autoLatched); + EXPECT_EQ(resolveSourceSelection({}, "zs-low", false, true).address, std::string("zs-low")); + } + { // already latched + stale index: index is re-derived from the string + auto r = resolveSourceSelection(addresses, "R-high", true, true); + EXPECT_EQ(r.index, 3); + EXPECT_TRUE(r.autoLatched); + } } // S8: deferFor() is an absolute hold, independent of the page interval (the retry-storm fix).