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 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 21:42:23 -05:00
co-authored by Claude Opus 4.8
parent 579accd0c6
commit d4339be94b
4 changed files with 89 additions and 9 deletions
+26
View File
@@ -43,6 +43,32 @@ int bestSpendableAddressIndex(const std::vector<AddressInfo>& addresses)
return bestIndex; return bestIndex;
} }
int addressIndexByString(const std::vector<AddressInfo>& 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<int>(i);
}
return -1;
}
SourceSelection resolveSourceSelection(const std::vector<AddressInfo>& 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 std::string TransactionInfo::getTimeString() const
{ {
if (timestamp == 0) return "Unknown"; if (timestamp == 0) return "Unknown";
+14
View File
@@ -42,6 +42,20 @@ struct AddressInfo {
std::vector<size_t> sortedSpendableAddressIndices(const std::vector<AddressInfo>& addresses, std::vector<size_t> sortedSpendableAddressIndices(const std::vector<AddressInfo>& addresses,
bool requirePositiveBalance = true); bool requirePositiveBalance = true);
int bestSpendableAddressIndex(const std::vector<AddressInfo>& addresses); int bestSpendableAddressIndex(const std::vector<AddressInfo>& addresses);
// Index of the address whose string equals `address` (exact match), or -1 (empty string / not listed).
int addressIndexByString(const std::vector<AddressInfo>& 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<AddressInfo>& addresses, const std::string& current,
bool autoLatched, bool connected);
/** /**
* @brief Represents a wallet transaction * @brief Represents a wallet transaction
+16 -9
View File
@@ -246,15 +246,16 @@ static void RenderSourceDropdown(App* app, float width) {
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("send_sending_from")); Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("send_sending_from"));
ImGui::Dummy(ImVec2(0, Layout::spacingSm())); ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Auto-select the address with the largest balance on first load // s_from_address is the single source of truth (it is what gets debited). Auto-select the address with the
if (!s_auto_selected && app->isConnected() && !state.addresses.empty()) { // largest balance on first load ONLY when no source is set yet - never over a "Send from this address"
int bestIdx = bestSpendableAddressIndex(state.addresses); // pre-fill from another tab - and re-derive the list index from the string every frame so it can't desync
if (bestIdx >= 0) { // when the address list changes.
s_selected_from_idx = bestIdx; {
snprintf(s_from_address, sizeof(s_from_address), "%s", const auto sel = resolveSourceSelection(state.addresses, s_from_address, s_auto_selected, app->isConnected());
state.addresses[bestIdx].address.c_str()); if (sel.address != s_from_address)
} snprintf(s_from_address, sizeof(s_from_address), "%s", sel.address.c_str());
s_auto_selected = true; s_auto_selected = sel.autoLatched;
s_selected_from_idx = sel.index;
} }
// Build preview string for selected address // Build preview string for selected address
@@ -271,6 +272,11 @@ static void RenderSourceDropdown(App* app, float width) {
tag, trunc.c_str(), tag, trunc.c_str(),
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str()); FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
s_source_preview = buf; 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<int>(std::max(S.drawElement("tabs.send", "addr-preview-trunc-min").size, width / S.drawElement("tabs.send", "addr-preview-trunc-divisor").size)));
} else { } else {
s_source_preview = TR("send_select_source"); 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); strncpy(s_from_address, address.c_str(), sizeof(s_from_address) - 1);
s_from_address[sizeof(s_from_address) - 1] = '\0'; s_from_address[sizeof(s_from_address) - 1] = '\0';
s_selected_from_idx = -1; s_selected_from_idx = -1;
s_auto_selected = true; // an explicit choice: the first-visit auto-pick must never replace it
} }
} // namespace ui } // namespace ui
+33
View File
@@ -1131,6 +1131,39 @@ void testSpendableFiltering()
auto includeZero = dragonx::sortedSpendableAddressIndices(addresses, false); auto includeZero = dragonx::sortedSpendableAddressIndices(addresses, false);
EXPECT_EQ(includeZero.size(), static_cast<size_t>(3)); EXPECT_EQ(includeZero.size(), static_cast<size_t>(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). // S8: deferFor() is an absolute hold, independent of the page interval (the retry-storm fix).