5 Commits
Author SHA1 Message Date
DanSandClaude Opus 4.8 e9eeaf00c3 feat(chat): Accept/Block card for unsolicited contact requests
An unsolicited first message from a stranger (an incoming ContactRequest, with
nothing sent by us and the peer not in contacts) now shows an Accept / Block
card above the thread instead of looking like a normal conversation. Accept adds
the peer to the address book (which clears the request state); Block deletes and
blocks the conversation (reversible from the Blocked manager). ConvSummary gains
a derived pendingRequest flag. (The list-row "Request" pill and a header Block
button were left out to avoid the fragile hand-drawn header-toolbar geometry.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 13:12:57 -05:00
DanSandClaude Opus 4.8 572b98cbcf feat(shield): pick the mining address from a dropdown + show a confirm preview
Shield-coinbase replaced its free-text "*"-wildcard address box with a dropdown
of the wallet's mining (transparent) addresses and their balances ("All mining
addresses" default), and the confirm step now previews "Shield up to X DRGX from
N mining address(es)… to <label>? Network fee: Y" instead of only relabeling the
button. Also blocks submitting from an empty source or one with no funds. The
z_shieldcoinbase call (args/fee/limit/destination) is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 13:09:41 -05:00
DanSandClaude Opus 4.8 bcfc324426 feat(balance): labeled "Pending +X" chip with tooltip in more layouts
The unconfirmed balance was shown in only one layout as an unlabeled "+X" that
read as a mystery. It's now a labeled "Pending +X" chip with an "awaiting
confirmations" tooltip — the Classic card's badge is relabeled, and the shared
compact hero (Dashboard + Two-row layouts) now draws it too. (The remaining
hand-drawn layouts can reuse the same pattern as a follow-up.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 13:07:43 -05:00
DanSandClaude Opus 4.8 7cea9b7339 feat(bootstrap,validate): Retry keeps the mirror; Validate works offline
- Bootstrap: Retry now reuses the source the user actually started (was always
  the default), and a "Try mirror" button offers the alternate server on
  failure (s_lastUrl remembered on start, cleared on a fresh Confirm).
- Validate Address: classification + checksum are local, so the dialog no
  longer requires a node connection — offline/lite now validate and show the
  type, with ownership reported as "Unknown — node offline / not checkable in
  the lite wallet" instead of a dead "not connected" screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 13:05:40 -05:00
DanSandClaude Opus 4.8 f3f0b32a48 feat(ux): robust sync ETA, persist Explorer settings, gate export-all out of lite
- Sync ETA: a new pure util::SyncRateEstimator (App-owned) replaces the
  function-local statics that went stale after a rescan/restart (producing the
  "412h" ETA). It self-resets on a height drop and on connect/disconnect/rescan/
  restart (via an atomic flag, since onConnected can run off-thread), formats
  with days, and uses the Warning() token.
- Settings: the Explorer-tab URL fields + custom-fees/fetch-prices checkboxes
  now persist on edit — they were the only controls still relying on the
  About-only Save button that a user on this tab never sees.
- Export-all-keys: hidden in the lite build (which has its own key export),
  instead of dead-ending on "Not connected to the daemon".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 13:02:46 -05:00
12 changed files with 258 additions and 62 deletions
+13 -37
View File
@@ -3084,45 +3084,19 @@ void App::renderStatusBar()
int blocksLeft = chainTip - state_.sync.blocks;
if (blocksLeft < 0) blocksLeft = 0;
// Calculate sync speed (blocks/sec) using a smoothed rolling average
static int s_prev_blocks = 0;
static double s_prev_time = 0.0;
static double s_blocks_per_sec = 0.0;
double now = ImGui::GetTime();
if (s_prev_time == 0.0) {
// First sample — seed baseline, don't compute rate yet
s_prev_blocks = state_.sync.blocks;
s_prev_time = now;
} else if (state_.sync.blocks > s_prev_blocks) {
double dt = now - s_prev_time;
if (dt > 0.5) {
double raw = (state_.sync.blocks - s_prev_blocks) / dt;
// Exponential smoothing (alpha ~0.3 per update)
s_blocks_per_sec = s_blocks_per_sec > 0.0
? s_blocks_per_sec * 0.7 + raw * 0.3
: raw;
s_prev_blocks = state_.sync.blocks;
s_prev_time = now;
}
} else if (now - s_prev_time > 10.0) {
// No new blocks for 10s — decay the displayed rate
s_blocks_per_sec *= 0.5;
s_prev_time = now;
}
if (s_blocks_per_sec > 0.1) {
int eta_sec = (int)(blocksLeft / s_blocks_per_sec);
// App-owned smoothed sync-rate estimator: self-resets after a rescan/restart height drop (and on
// connect/disconnect via the pending flag), so the ETA never goes stale/absurd ("412h"); days-aware.
if (sync_eta_reset_pending_.exchange(false)) sync_eta_.reset();
sync_eta_.update(state_.sync.blocks, ImGui::GetTime());
const ImVec4 syncCol = ImGui::ColorConvertU32ToFloat4(ui::material::Warning());
const long long etaSec = sync_eta_.etaSeconds(blocksLeft);
if (etaSec >= 0) {
char eta[32];
if (eta_sec >= 3600)
snprintf(eta, sizeof(eta), "%dh %dm", eta_sec / 3600, (eta_sec % 3600) / 60);
else if (eta_sec >= 60)
snprintf(eta, sizeof(eta), "%dm %ds", eta_sec / 60, eta_sec % 60);
else
snprintf(eta, sizeof(eta), "%ds", eta_sec);
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), TR("sb_syncing_eta"),
state_.sync.verification_progress * 100.0, blocksLeft, s_blocks_per_sec, eta);
util::formatEta(etaSec, eta, sizeof(eta));
ImGui::TextColored(syncCol, TR("sb_syncing_eta"),
state_.sync.verification_progress * 100.0, blocksLeft, sync_eta_.rate(), eta);
} else {
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), TR("sb_syncing_basic"),
ImGui::TextColored(syncCol, TR("sb_syncing_basic"),
state_.sync.verification_progress * 100.0, blocksLeft);
}
} else if (state_.connected && !isLiteBuild()) {
@@ -6337,6 +6311,7 @@ void App::rescanBlockchain()
// actually observe the restarted daemon rescanning — so the first poll (which may still reach the
// pre-restart daemon and see rescanning=false) can't be misread as instant completion.
state_.sync.rescanning = true;
sync_eta_reset_pending_.store(true, std::memory_order_relaxed); // rescan drops height → reset ETA
rescan_confirmed_active_ = false;
user_initiated_rescan_ = true; // user-triggered rescan → its completion should toast
state_.sync.rescan_progress = 0.0f;
@@ -8136,6 +8111,7 @@ std::string App::buildDiagnosticsReport()
void App::restartDaemon()
{
sync_eta_reset_pending_.store(true, std::memory_order_relaxed);
if (!supportsFullNodeLifecycleActions()) {
ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
return;
+5
View File
@@ -42,6 +42,7 @@
#include "resources/provision_state.h"
#include "util/background_sampler.h"
#include "util/coalescing_writer.h"
#include "util/sync_eta.h"
#include "util/fetch_backoff.h"
#include "util/idle_tracker.h"
#include "util/pool_stats_service.h"
@@ -1752,6 +1753,10 @@ private:
std::atomic<bool> mining_toggle_in_progress_{false};
// Mining error state (main-thread only — every writer is in a MainCb or render).
std::string mining_last_error_;
// Sync-ETA estimator (UI thread only). onConnected/onDisconnected may run off-thread, so they set
// the atomic flag and the status bar consumes it before update().
util::SyncRateEstimator sync_eta_;
std::atomic<bool> sync_eta_reset_pending_{false};
bool mining_error_av_ = false;
void setMiningError(std::string msg, bool av = false) { mining_last_error_ = std::move(msg); mining_error_av_ = av; }
+2
View File
@@ -741,6 +741,7 @@ void App::tryConnect()
void App::onConnected()
{
maint_abort_.store(false); // maintenance calls queued before this connect have already failed (FIFO)
sync_eta_reset_pending_.store(true, std::memory_order_relaxed); // fresh connect → recompute sync ETA
++rpc_session_;
if (consolidation_.abort()) ui::Notifications::instance().warning(TR("merge_run_aborted_toast"));
state_.connected = true;
@@ -854,6 +855,7 @@ void App::onConnected()
void App::onDisconnected(const std::string& reason)
{
sync_eta_reset_pending_.store(true, std::memory_order_relaxed);
// Persist what the history snapshot is still missing while the rows, the address set (the cache identity),
// the pending-send filter and the cache key are all live: state_.clear(), pending_opids_.clear() and the
// session reset below empty them, which would turn a later flush into a silent no-op.
+15 -5
View File
@@ -1548,9 +1548,13 @@ void RenderSettingsPage(App* app) {
if (cardBtn(cx, cy, cw, "##exp_key", TR("settings_export_key"), ICON_MD_LOGOUT)) app->showExportKeyDialog();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_key"));
last = cy + bh; cy += bh + bgp;
if (cardBtn(cx, cy, cw, "##exp_all", TR("settings_export_all"), ICON_MD_ARCHIVE)) ExportAllKeysDialog::show();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_all"));
last = cy + bh; cy += bh + bgp;
// Export-all only works over the full-node RPC; hide it in lite (which has its own key export
// in the lite wallet section) rather than dead-ending on "Not connected to the daemon".
if (fullNode) {
if (cardBtn(cx, cy, cw, "##exp_all", TR("settings_export_all"), ICON_MD_ARCHIVE)) ExportAllKeysDialog::show();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_all"));
last = cy + bh; cy += bh + bgp;
}
if (cardBtn(cx, cy, cw, "##exp_csv", TR("settings_export_csv"), ICON_MD_DESCRIPTION)) ExportTransactionsDialog::show();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_csv"));
last = cy + bh;
@@ -2727,7 +2731,10 @@ void RenderSettingsPage(App* app) {
ImGui::TextUnformatted(TR("transaction_url"));
ImGui::SameLine(urlRowX + urlLblW);
ImGui::SetNextItemWidth(urlInputW);
// These Explorer-tab controls persist on edit — they were the only ones still relying on the
// About-only Save button, which a user on this tab never sees.
ImGui::InputText("##TxExplorer", s_settingsState.tx_explorer, sizeof(s_settingsState.tx_explorer));
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_tx_url"));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
@@ -2737,6 +2744,7 @@ void RenderSettingsPage(App* app) {
ImGui::SameLine(urlRowX + urlLblW);
ImGui::SetNextItemWidth(urlInputW);
ImGui::InputText("##AddrExplorer", s_settingsState.addr_explorer, sizeof(s_settingsState.addr_explorer));
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_addr_url"));
ImGui::PopFont();
@@ -2754,10 +2762,12 @@ void RenderSettingsPage(App* app) {
// Checkboxes + Block Explorer button (button wraps to its own row when it won't fit).
const float expRowRight = ImGui::GetCursorScreenPos().x + contentW;
ImGui::Checkbox(TrId("custom_fees", "custom_fees").c_str(), &s_settingsState.allow_custom_fees);
if (ImGui::Checkbox(TrId("custom_fees", "custom_fees").c_str(), &s_settingsState.allow_custom_fees))
saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_custom_fees"));
ImGui::SameLine(0, Layout::spacingLg());
ImGui::Checkbox(TrId("fetch_prices", "fetch_prices").c_str(), &s_settingsState.fetch_prices);
if (ImGui::Checkbox(TrId("fetch_prices", "fetch_prices").c_str(), &s_settingsState.fetch_prices))
saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_fetch_prices"));
const float expBtnW = ImGui::CalcTextSize(TR("block_explorer")).x
+ ImGui::GetStyle().FramePadding.x * 2.0f + Layout::spacingLg();
+12
View File
@@ -136,6 +136,18 @@ void RenderCompactHero(App* app, ImDrawList* dl, float availW, float hs, float v
ImGui::Dummy(ImVec2(capFont->CalcTextSizeA(tickerFontSize, 10000, 0, DRAGONX_TICKER).x + Layout::spacingLg(), lineH));
ImGui::SameLine();
// Pending (unconfirmed) chip — so these layouts show it too, labeled, not just the Classic card.
if (s_dispUnconfirmed > 1e-9) {
snprintf(buf, sizeof(buf), TR("baltab_pending_chip"), s_dispUnconfirmed);
ImVec2 pp = ImGui::GetCursorScreenPos();
dl->AddText(capFont, tickerFontSize, ImVec2(pp.x, tickerY), WithAlpha(Warning(), 230), buf);
float pw = capFont->CalcTextSizeA(tickerFontSize, 10000, 0, buf).x;
if (material::IsRectHovered(ImVec2(pp.x, tickerY), ImVec2(pp.x + pw, tickerY + tickerFontSize)))
material::Tooltip("%s", TR("baltab_pending_tip"));
ImGui::Dummy(ImVec2(pw + Layout::spacingLg(), lineH));
ImGui::SameLine();
}
// Total period-earnings (left of the USD) when the global earnings toggle is on.
{
std::string earnText; ImU32 earnCol;
+3 -2
View File
@@ -525,9 +525,9 @@ static void RenderBalanceClassic(App* app)
WithAlpha(Success(), 160), buf);
}
// Unconfirmed badge (top-right corner)
// Pending badge (top-right corner) — labeled so an unconfirmed "+X" doesn't read as a mystery.
if (state.unconfirmed_balance > 0.0) {
snprintf(buf, sizeof(buf), "+%.4f", state.unconfirmed_balance);
snprintf(buf, sizeof(buf), TR("baltab_pending_chip"), state.unconfirmed_balance);
ImVec2 ts = capFont->CalcTextSizeA(
capFont->LegacySize, 10000, 0, buf);
float bp = S.drawElement("tabs.balance.classic", "unconfirmed-badge-padding").sizeOr(4.0f) * dp;
@@ -540,6 +540,7 @@ static void RenderBalanceClassic(App* app)
dl->AddText(capFont, capFont->LegacySize,
ImVec2(bMin.x + bp, bMin.y + bp * 0.5f),
amberCol, buf);
if (material::IsRectHovered(bMin, bMax)) material::Tooltip("%s", TR("baltab_pending_tip"));
}
// Hover glow + click to Receive
+10 -1
View File
@@ -45,6 +45,7 @@ public:
s_state = State::Confirm;
s_bootstrap.reset();
s_errorMsg.clear();
s_lastUrl.clear();
s_wasDaemonRunning = false;
}
@@ -314,9 +315,15 @@ private:
float btnW = 120.0f * dp;
if (TactileButton(TR("retry"), ImVec2(btnW, 0))) {
startDownload("");
startDownload(s_lastUrl); // keep the user's source/mirror choice (was startDownload(""))
}
ImGui::SameLine();
{
const std::string mirrorUrl = std::string(util::Bootstrap::kMirrorUrl) + "/" + util::Bootstrap::kZipName;
if (s_lastUrl != mirrorUrl && TactileButton(TR("bootstrap_try_mirror"), ImVec2(130.0f * dp, 0)))
startDownload(mirrorUrl);
ImGui::SameLine();
}
if (TactileButton(TR("close"), ImVec2(90.0f * dp, 0))) {
s_app->setBootstrapDownloading(false);
s_open = false;
@@ -336,6 +343,7 @@ private:
s_bootstrap->start(dataDir);
else
s_bootstrap->start(dataDir, url);
s_lastUrl = url; // remember what we started (set only after the guards passed)
s_state = State::Downloading;
s_errorMsg.clear();
s_cancelling = false;
@@ -347,6 +355,7 @@ private:
static inline std::unique_ptr<util::Bootstrap> s_bootstrap;
static inline bool s_wasDaemonRunning = false;
static inline bool s_cancelling = false; // cancel requested; disables the Cancel button until the worker stops
static inline std::string s_lastUrl; // last source started ("" = default); so Retry keeps the mirror choice
static inline std::string s_errorMsg;
};
+45
View File
@@ -537,6 +537,7 @@ struct ConvSummary {
std::int64_t lastTs = 0;
int count = 0;
bool hidden = false; // shown only while "Show hidden" is on
bool pendingRequest = false; // unsolicited incoming contact request from a stranger: awaiting Accept/Block
};
// Per-frame memoization of the conversation-list build and the open-thread message list (both otherwise
@@ -808,9 +809,11 @@ void RenderChatTab(App* app)
c.cid = cid;
c.hidden = hidden;
c.count = static_cast<int>(messages.size());
bool sentAny = false;
for (const auto& m : messages) { // pin to the EARLIEST (establishing) peer z-addr / key (B2 — the
if (c.peerZaddr.empty() && !m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr; // memo header rides
if (c.peerPubKey.empty() && !m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex; // outside the AEAD)
if (m.direction == chat::ChatDirection::Outgoing) sentAny = true;
}
const auto& last = messages.back();
c.lastBody = last.body;
@@ -818,6 +821,12 @@ void RenderChatTab(App* app)
const int idx = c.peerZaddr.empty() ? -1 : book.findByAddress(c.peerZaddr);
c.peerName = (idx >= 0) ? book.entries()[idx].label
: shorten(!c.peerZaddr.empty() ? c.peerZaddr : cid);
// Unsolicited: first message is an incoming contact request, we've never replied, and the peer
// isn't a saved contact → show an Accept/Block card instead of treating it as a normal chat.
const auto& first = messages.front();
c.pendingRequest = (idx < 0) && !sentAny && !c.peerZaddr.empty() &&
first.direction == chat::ChatDirection::Incoming &&
first.kind == chat::ChatMessageKind::ContactRequest;
s_convs.push_back(std::move(c));
}
std::sort(s_convs.begin(), s_convs.end(),
@@ -1340,6 +1349,42 @@ void RenderChatTab(App* app)
ImGui::SetCursorScreenPos(ImVec2(hp.x, hp.y + rowH + 4.0f * hdpi));
ImGui::Separator();
// Stranger contact-request card: Accept (add to contacts) or Block (delete + block). Normal
// ImGui flow, so ##ChatMessages below (sized from the remaining content height) shrinks to fit.
if (sel->pendingRequest) {
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::Warning()));
ImGui::TextUnformatted(TR("chat_request_title"));
ImGui::PopStyleColor();
ImGui::PushTextWrapPos(0.0f);
ImGui::TextWrapped("%s", TR("chat_request_body"));
ImGui::PopTextWrapPos();
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
if (ImGui::SmallButton(TR("chat_request_accept"))) {
data::AddressBookEntry e;
e.address = sel->peerZaddr;
e.label = sel->peerName;
book.addEntry(e); // book.revision() bumps → the request flag clears on the next rebuild
Notifications::instance().info(TR("chat_request_accepted"));
}
ImGui::SameLine();
if (ImGui::SmallButton(TR("chat_request_block"))) {
const std::string cid = sel->cid, nm = sel->peerName; // copy before deleteConversation
if (app->chatService().deleteConversation(cid, /*block=*/true)) {
if (app->settings()) {
app->settings()->setChatBlocked(cid, nm, true);
app->settings()->setChatHidden(cid, false);
app->settings()->save();
}
if (s_selected_cid == cid) s_selected_cid.clear();
Notifications::instance().info(TR("chat_blocked_toast"));
} else {
Notifications::instance().error(TR("chat_delete_failed"));
}
}
ImGui::Separator();
}
const float dp = Layout::dpiScale();
// Small message-list inset (the ##ChatThread WindowPadding above already holds content off
// the glass edge; keep a little here so bubbles don't hug the scrollbar).
+60 -2
View File
@@ -204,6 +204,25 @@ static double srcAmount() { return s_src == 0 ? s_t_amount : s_src == 1 ? s_z_am
static std::string fmtAmt(double v) { char b[48]; std::snprintf(b, sizeof(b), "%.4f", v); return b; }
// Shield-coinbase source helpers. "*" = all spendable mining (transparent) addresses; else one address.
// Balances are DISPLAY totals (may include immature/non-coinbase), so the UI wording says "up to".
static double miningBalance(const WalletState& st, const char* from) {
if (std::strcmp(from, "*") == 0) {
double sum = 0.0;
for (const auto& a : st.t_addresses) if (a.isSpendable()) sum += a.balance;
return sum;
}
const int i = addressIndexByString(st.t_addresses, from);
return i >= 0 ? st.t_addresses[i].balance : 0.0;
}
static int miningAddrCount(const WalletState& st, const char* from) {
if (std::strcmp(from, "*") == 0) {
int n = 0; for (const auto& a : st.t_addresses) if (a.isSpendable() && a.balance > 0.0) ++n; return n;
}
const int i = addressIndexByString(st.t_addresses, from);
return (i >= 0 && st.t_addresses[i].balance > 0.0) ? 1 : 0;
}
// A caption-style line that wraps at the dialog edge (Type().textColored doesn't wrap).
static void wrappedCaption(ImU32 color, const char* text)
{
@@ -567,8 +586,32 @@ void ShieldDialog::render(App* app)
// ── Shield coinbase: from address ───────────────────────────────────────────────────────────
if (!isMerge && !opInFlight) {
material::LabeledInput(TR("shield_from_address"), "##FromAddr", s_from_address, sizeof(s_from_address));
ImGui::TextDisabled("%s", TR("shield_wildcard_hint"));
// Pick the mining (transparent) address to shield from a dropdown with balances, instead of a
// free-text box with a "*" wildcard. "*" shields all of them.
ImGui::TextUnformatted(TR("shield_from_address"));
const bool allFrom = std::strcmp(s_from_address, "*") == 0;
const std::string fromLbl = allFrom ? std::string(TR("shield_all_mining_addrs"))
: (app->getAddressLabel(s_from_address).empty() ? shortAddr(s_from_address)
: app->getAddressLabel(s_from_address));
ImGui::SetNextItemWidth(-1);
if (ImGui::BeginCombo("##FromAddr", fromLbl.c_str())) {
if (ImGui::Selectable(TR("shield_all_mining_addrs"), allFrom))
strncpy(s_from_address, "*", sizeof(s_from_address));
for (const auto& a : state.t_addresses) {
if (!a.isSpendable()) continue;
const std::string lbl = app->getAddressLabel(a.address);
char item[192];
std::snprintf(item, sizeof(item), "%s (%s DRGX)",
lbl.empty() ? shortAddr(a.address).c_str() : lbl.c_str(),
fmtAmt(a.balance).c_str());
const bool sel = std::strcmp(s_from_address, a.address.c_str()) == 0;
if (ImGui::Selectable(item, sel)) {
strncpy(s_from_address, a.address.c_str(), sizeof(s_from_address) - 1);
s_from_address[sizeof(s_from_address) - 1] = '\0';
}
}
ImGui::EndCombo();
}
ImGui::Spacing();
}
@@ -833,9 +876,24 @@ void ShieldDialog::render(App* app)
fmtAmt(srcAmount()).c_str(), srcCount(), shortAddr(s_to_address).c_str());
ImGui::TextWrapped("%s", cb);
ImGui::Spacing();
} else if (s_confirm && !isMerge) {
// Shield-coinbase preview: amount / address count / fee / destination (no amount/count was shown before).
const bool allFrom = std::strcmp(s_from_address, "*") == 0;
const std::string toLbl = app->getAddressLabel(s_to_address);
char cb[360];
std::snprintf(cb, sizeof(cb), TR("shield_confirm_fmt"),
fmtAmt(miningBalance(state, s_from_address)).c_str(),
allFrom ? miningAddrCount(state, s_from_address) : 1,
s_utxo_limit,
toLbl.empty() ? shortAddr(s_to_address).c_str() : toLbl.c_str(),
fmtAmt(s_fee).c_str());
ImGui::TextWrapped("%s", cb);
ImGui::Spacing();
}
bool can_submit = haveDest && connected && !syncing;
// Block a shield from an empty source or one with no funds (the free-text box allowed both).
if (!isMerge && (s_from_address[0] == '\0' || miningBalance(state, s_from_address) <= 0.0)) can_submit = false;
const bool scopeLoaded = s_scan.phase == services::ScopePhase::Loaded;
if (isMerge && scopeLoaded && srcCount() == 0) can_submit = false;
if (perAddress) can_submit = connected && !syncing && scopeLoaded && !selectedGroups().empty();
+16 -14
View File
@@ -91,7 +91,8 @@ void ValidateAddressDialog::render(App* app)
ImGui::Spacing();
// Validate button
bool can_validate = strlen(s_address_input) > 0 && !s_validating && app->isConnected();
// Classification + checksum are LOCAL, so allow validating offline (the is-mine check needs the node).
bool can_validate = strlen(s_address_input) > 0 && !s_validating;
if (!can_validate) {
ImGui::BeginDisabled();
@@ -123,8 +124,9 @@ void ValidateAddressDialog::render(App* app)
s_address_type.clear();
s_validated = true;
s_validating = false;
} else if (app->isLiteBuild()) {
// Lite has no node to ask: the checksum verdict is the answer, ownership is unknown (hidden).
} else if (app->isLiteBuild() || !app->isConnected()) {
// No node to ask (lite, or full-node offline): the checksum verdict is the answer; ownership
// is unknown. The result block shows why.
s_is_valid = true;
s_is_mine = false;
s_ownership_known = false;
@@ -209,15 +211,17 @@ void ValidateAddressDialog::render(App* app)
ImGui::SameLine(lbl.position);
ImGui::Text("%s", s_address_type.c_str());
// Is mine? (not shown when no node was asked - lite)
if (s_ownership_known) {
ImGui::Text("%s", TR("validate_ownership"));
ImGui::SameLine(lbl.position);
if (s_is_mine) {
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("validate_is_mine"));
} else {
ImGui::TextDisabled("%s", TR("validate_not_mine"));
}
// Is mine? When no node was asked (lite / offline) say why ownership is unknown rather
// than silently omitting it.
ImGui::Text("%s", TR("validate_ownership"));
ImGui::SameLine(lbl.position);
if (!s_ownership_known) {
ImGui::TextDisabled("%s", TR(app->isLiteBuild() ? "validate_ownership_unknown_lite"
: "validate_ownership_unknown_offline"));
} else if (s_is_mine) {
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("validate_is_mine"));
} else {
ImGui::TextDisabled("%s", TR("validate_not_mine"));
}
if (s_is_mining) { // valid, but not for payments
ImGui::Spacing();
@@ -227,8 +231,6 @@ void ValidateAddressDialog::render(App* app)
}
}
}
} else if (!app->isConnected()) {
ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.0f, 1.0f), "%s", TR("not_connected"));
} else {
// Pre-interaction placeholder: keeps the results area from reading as a large
// dead gap between the Validate/Paste row and the Close button until a result exists.
+13 -1
View File
@@ -232,6 +232,11 @@ void I18n::loadBuiltinEnglish()
strings_["chat_empty_hint"] = "No conversations yet. Messages you receive will appear here.";
strings_["chat_you"] = "You";
strings_["chat_contact_request"] = "contact request";
strings_["chat_request_title"] = "New contact request";
strings_["chat_request_body"] = "This person isn't in your contacts. Accept to add them, or block to delete this conversation and ignore them.";
strings_["chat_request_accept"] = "Accept";
strings_["chat_request_block"] = "Block";
strings_["chat_request_accepted"] = "Contact added.";
strings_["chat_send_failed"] = "not sent";
strings_["chat_status_sending"] = "Sending… waiting for your wallet to broadcast it";
strings_["chat_status_sent"] = "Sent to the network — it arrives after it confirms (about a minute)";
@@ -688,6 +693,7 @@ void I18n::loadBuiltinEnglish()
strings_["wiz_bootstrap_desc"] = "Download a blockchain bootstrap to dramatically speed up initial sync.\n\nYour existing wallet.dat will NOT be modified or replaced.";
strings_["wiz_skip"] = "Skip";
strings_["wiz_bootstrap_failed"] = "Bootstrap failed";
strings_["bootstrap_try_mirror"] = "Try mirror";
strings_["wiz_download_failed"] = "Download Failed";
strings_["wiz_ext_daemon_running"] = "External daemon running";
strings_["wiz_ext_daemon_warning"] = "It must be stopped before downloading a bootstrap, otherwise chain data could be corrupted.";
@@ -1451,6 +1457,8 @@ void I18n::loadBuiltinEnglish()
strings_["confirmations"] = "Confirmations";
strings_["confirmed"] = "Confirmed";
strings_["pending"] = "Pending";
strings_["baltab_pending_chip"] = "Pending +%.4f";
strings_["baltab_pending_tip"] = "Awaiting confirmations — not spendable yet; it moves to your available balance once confirmed.";
strings_["sent"] = "sent";
strings_["received"] = "received";
strings_["mined"] = "mined";
@@ -2618,7 +2626,9 @@ void I18n::loadBuiltinEnglish()
strings_["shield_check_status"] = "Check Status";
strings_["shield_completed"] = "Operation completed successfully!";
strings_["shield_description"] = "Shield your mining rewards by sending coinbase outputs from transparent addresses to a shielded address. This improves privacy by hiding your mining income.";
strings_["shield_from_address"] = "From Address:";
strings_["shield_from_address"] = "From mining address:";
strings_["shield_all_mining_addrs"] = "All mining addresses";
strings_["shield_confirm_fmt"] = "Shield up to %s DRGX from %d mining address(es) (at most %d rewards per run) to %s? Network fee: %s DRGX.";
strings_["shield_funds"] = "Shield Funds";
strings_["shield_in_progress"] = "Operation in progress...";
strings_["shield_max_utxos"] = "Max UTXOs per operation";
@@ -2723,6 +2733,8 @@ void I18n::loadBuiltinEnglish()
strings_["validate_invalid"] = "INVALID";
strings_["validate_is_mine"] = "This wallet owns this address";
strings_["validate_not_mine"] = "Not owned by this wallet";
strings_["validate_ownership_unknown_offline"] = "Unknown — node offline";
strings_["validate_ownership_unknown_lite"] = "Unknown — not checkable in the lite wallet";
strings_["validate_ownership"] = "Ownership:";
strings_["validate_results"] = "Results:";
strings_["validate_results_placeholder"] = "Results will appear here";
+64
View File
@@ -0,0 +1,64 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// A tiny, pure block-sync rate estimator for the status-bar / peers-card ETA. Pure (no ImGui, no App):
// App owns one instance, feeds it the current height + a monotonic clock once per frame while syncing,
// and resets it on connect / disconnect / rescan / restart so a height drop never produces a stale or
// absurd ("412h") ETA.
#pragma once
#include <cstddef>
#include <cstdio>
namespace dragonx {
namespace util {
class SyncRateEstimator {
public:
void reset() { prevBlocks_ = -1; prevTime_ = 0.0; rate_ = 0.0; }
// Call once per frame while syncing. `now` is monotonic seconds (e.g. ImGui::GetTime()).
void update(int blocks, double now) {
// First sample, a rescan/restart height drop, or a clock reset → re-baseline, don't compute.
if (prevBlocks_ < 0 || blocks < prevBlocks_ || now < prevTime_) {
prevBlocks_ = blocks; prevTime_ = now; rate_ = 0.0; return;
}
const double dt = now - prevTime_;
if (blocks > prevBlocks_) {
if (dt > 0.5) {
const double raw = (blocks - prevBlocks_) / dt;
rate_ = rate_ > 0.0 ? rate_ * 0.7 + raw * 0.3 : raw; // smooth (same alpha as the old code)
prevBlocks_ = blocks; prevTime_ = now;
}
} else if (dt > 10.0) { // stalled: decay and re-baseline the clock
rate_ *= 0.5; prevTime_ = now;
if (rate_ < 0.1) rate_ = 0.0;
}
}
double rate() const { return rate_; } // blocks/sec, 0 when unknown
// <= 0 when unknown. Capped at ~1 year so a tiny rate can't overflow.
long long etaSeconds(int blocksLeft) const {
if (rate_ <= 0.1 || blocksLeft <= 0) return -1;
const double s = blocksLeft / rate_;
return s > 3.15e7 ? 31500000LL : static_cast<long long>(s);
}
private:
int prevBlocks_ = -1; // -1 = unset (distinct from a legitimate height 0)
double prevTime_ = 0.0;
double rate_ = 0.0;
};
// Human ETA with days, e.g. "3d 4h", "5h 12m", "7m 9s", "42s". English unit letters (as the old code).
inline void formatEta(long long s, char* out, std::size_t n) {
if (s >= 86400) std::snprintf(out, n, "%lldd %lldh", s / 86400, (s % 86400) / 3600);
else if (s >= 3600) std::snprintf(out, n, "%lldh %lldm", s / 3600, (s % 3600) / 60);
else if (s >= 60) std::snprintf(out, n, "%lldm %llds", s / 60, s % 60);
else std::snprintf(out, n, "%llds", s);
}
} // namespace util
} // namespace dragonx