8 Commits

Author SHA1 Message Date
dd9bc0bd2b fix: Tier-2 involved mediums — send confirm re-validation + console JSON errors
- Send: re-validate against LIVE state at the "Confirm & Send" click (connected,
  not syncing, amount > 0, total <= available). The confirm dialog persists
  across frames, so a balance drop / sync restart / fee bump after Review could
  otherwise broadcast a now-invalid transaction; it now shows a clear message
  instead of sending.
- Console: a malformed JSON argument ({...}/[...] that fails to parse) was
  silently sent to the daemon as a plain string. Now the command fails with a
  clear "Argument N is not valid JSON" line instead of being silently mangled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 14:00:16 -05:00
b2a8c4487e fix: Tier-2 quick mediums — view-only guard, memo counter, updater/close guards
- Send: block Review from a view-only source (no spending key) with a tooltip,
  instead of failing at submit; label the memo counter as bytes and warn in
  colour once the field hits its cap (Sapling memos are byte-limited).
- Shield: show "No shielded (z) address yet — create one on the Receive tab
  first" when the destination combo is empty, instead of an empty dropdown.
- Updater dialogs (xmrig/daemon): suppress the window X during an active
  download/verify/extract so closing can't orphan/block on the worker — the
  in-dialog Cancel is the way to abort.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 13:54:55 -05:00
65bb98cd09 fix: Tier-2 mediums — input trims, confirmations, amount normalization
More robustness fixes from the audit (the import-key pattern, lower severity):

- Trim pasted input before use/validation: network add-server URL/label,
  lite-wallet key import (+ block empty), validate-address, address-book entry.
- Confirmations for destructive/irreversible actions:
  - Address-book Delete now needs a confirming second click (no undo).
  - Console `stop` needs a confirming second `stop` (it shuts down the node).
  - Wizard "Skip" encryption needs a confirming second click (keys stored
    unencrypted).
- Send amount: normalize to 8dp (satoshi precision) on both the DRGX and USD
  inputs so digits past 8dp aren't silently dropped between the preview/review
  and what's actually sent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 13:18:57 -05:00
129a8e6449 fix: Tier-2 UX robustness — action guards, in-progress guards, input trimming
The app-wide "import-key pattern" fixes (missing guards / untrimmed input / no
re-entrancy protection), from the robustness audit:

- Import-key dialog: unify the type indicator and the RPC dispatch on one
  classifier (classifyPrivateKey is now prefix-aware — an "SK..." shielded key no
  longer misroutes to the transparent RPC), trim manual input (not just Paste),
  and gate both the indicator and the Import button on a shared
  isRecognizedPrivateKey() so unrecognized/empty input can't be submitted.
- Shield: guard the submit button on connected + !syncing (like Send), with a
  disabled tooltip, instead of firing into a raw daemon error.
- Mining: disable the pool Mine button when the payout address is empty
  ("enter a payout address first"), and trim the pool URL/worker on persist.
- Console: track in-flight RPC commands (atomic counter + busy() override) so the
  input is disabled while a command runs instead of piling up on the worker.
- Maintenance (rescan/repair/delete-chain/reinstall-daemon): re-entrancy guard —
  bail with "already in progress" instead of launching a duplicate destructive op.
- Bootstrap dialog: re-attach to an in-flight download on reopen instead of
  resetting state and orphaning the running worker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 13:09:23 -05:00
df14533ad3 fix: Tier-1 UX robustness — backup/export integrity, verified installs, input validation
From the app-wide robustness audit (calibrated to the import-key dialog). These
are the safety-critical fixes; several could cost users funds:

- Backup/export false success (FUND LOSS): exportAllKeys pre-seeded a header, so
  a keyless result (the usual case when the wallet is encrypted+locked) still
  looked non-empty and backupWallet wrote a private-key-less file and reported
  "Backup saved". Now exportAllKeys returns an exported count; backupWallet and
  the export-all dialog refuse to write / report success on 0 keys, disclose the
  count, flag partial results as INCOMPLETE, and the backup dialog confirms
  before overwriting an existing file (+ trims the path).
- Bootstrap: fail CLOSED — refuse to install an unverified multi-GB archive when
  no checksum is published (was `return true`), mirroring the xmrig/daemon updaters.
- Restore-from-seed: require a valid BIP39 word count (12/15/18/21/24) with a live
  "should be 24 words — you have N" hint instead of accepting any non-empty text.
- Encryption passphrase: detect leading/trailing whitespace and BLOCK with a
  warning (not a silent trim, which would change the passphrase and lock the user
  out).
- Receive payment QR: emit the canonical `drgx:` scheme with a URL-encoded memo
  via a new shared util::buildPaymentUri (the request-payment dialog now routes
  through it too, so they can't diverge); the old "dragonx:" + raw memo was
  unparseable by the wallet's own scanner.

Also clamps the receive "Recent Received" list to the 4 most recent (companion to
the recent-lists commit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 12:54:05 -05:00
24bb32eb61 feat(mining): label the pool fee inline + widen the auto-balance card
Each pool row showed "<hashrate>  N%" with nothing saying the % is the pool fee.
Make it "<hashrate>  N% fee" and widen the left auto-balance card (25%/180dp ->
30%/210dp min) so the added text fits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 12:53:46 -05:00
d3dcb5a015 feat(console): zebra contrast, edge outline, blinking caret, external-daemon status
- Lower the zebra-stripe contrast (scanline-line-alpha 4 -> 2; the derived dark
  band softens with it) so the alternating rows are subtler.
- Give the input's terminal bar a 1px outline along its own edges (theme-aware),
  matching the output panel's glass rim, and a near-white surface on light skins.
- Add a blinking terminal caret at the end of the input when it isn't focused.
- Report a daemon started before the wallet ("no wallet-managed EmbeddedDaemon"
  but RPC connected) as "Running" instead of the misleading "no daemon".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 12:53:46 -05:00
7249d11899 feat(ui): clamp recent-activity lists to the 4 most recent
The overview "Recent Transactions" and the send tab "Recent Sends" lists showed
every matching transaction in a scroll region. Cap them at the 4 most recent
(state.transactions is sorted newest-first, so a simple head-limit). The receive
"Recent Received" list gets the same clamp in the Tier-1 robustness commit
(same file as the QR fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 12:53:46 -05:00
31 changed files with 482 additions and 168 deletions

View File

@@ -931,7 +931,7 @@ scanline-speed = { size = 40.0 }
scanline-height = { size = 36.0 } scanline-height = { size = 36.0 }
scanline-alpha = { size = 8.0 } scanline-alpha = { size = 8.0 }
scanline-gap = { size = 2.0 } scanline-gap = { size = 2.0 }
scanline-line-alpha = { size = 4.0 } scanline-line-alpha = { size = 2.0 }
scanline-glow-spread = { size = 4.0 } scanline-glow-spread = { size = 4.0 }
scanline-glow-intensity = { size = 0.6 } scanline-glow-intensity = { size = 0.6 }
scanline-glow-color = { size = 255.0 } scanline-glow-color = { size = 255.0 }

View File

@@ -2509,7 +2509,28 @@ void App::renderLiteFirstRunPrompt()
while (!seedTrim.empty() && std::isspace((unsigned char)seedTrim.front())) seedTrim.erase(seedTrim.begin()); while (!seedTrim.empty() && std::isspace((unsigned char)seedTrim.front())) seedTrim.erase(seedTrim.begin());
while (!seedTrim.empty() && std::isspace((unsigned char)seedTrim.back())) seedTrim.pop_back(); while (!seedTrim.empty() && std::isspace((unsigned char)seedTrim.back())) seedTrim.pop_back();
ImGui::BeginDisabled(seedTrim.empty()); // Require a valid BIP39 word count before enabling Restore — otherwise a truncated or
// garbage phrase (previously any non-empty text passed) is submitted and fails opaquely.
int seedWords = 0;
{ bool inWord = false;
for (char c : seedTrim) {
bool sp = (c == ' ' || c == '\t' || c == '\n' || c == '\r');
if (!sp && !inWord) { seedWords++; inWord = true; }
else if (sp) inWord = false;
} }
bool seedLenOk = (seedWords == 12 || seedWords == 15 || seedWords == 18 ||
seedWords == 21 || seedWords == 24);
if (!seedTrim.empty() && !seedLenOk) {
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::Warning()));
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
ImGui::TextUnformatted(("Recovery phrase should be 24 words — you have " +
std::to_string(seedWords) + ".").c_str());
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
ImGui::Spacing();
}
ImGui::BeginDisabled(!seedLenOk);
if (ui::material::TactileButton(TR("lite_restore_btn"), ImVec2(btnW, 0))) { if (ui::material::TactileButton(TR("lite_restore_btn"), ImVec2(btnW, 0))) {
wallet::LiteWalletRestoreRequest req; wallet::LiteWalletRestoreRequest req;
req.seedPhrase = seedTrim; req.seedPhrase = seedTrim;
@@ -2629,20 +2650,24 @@ void App::renderImportKeyDialog()
memset(import_key_input_, 0, sizeof(import_key_input_)); memset(import_key_input_, 0, sizeof(import_key_input_));
} }
// Trimmed view for validation (importPrivateKey trims again before the RPC). The indicator and the
// Import-button guard below both derive from the SAME shared classifier, so they can't disagree.
std::string keyTrim(import_key_input_);
while (!keyTrim.empty() && (keyTrim.front()==' '||keyTrim.front()=='\t'||keyTrim.front()=='\n'||keyTrim.front()=='\r')) keyTrim.erase(keyTrim.begin());
while (!keyTrim.empty() && (keyTrim.back()==' '||keyTrim.back()=='\t'||keyTrim.back()=='\n'||keyTrim.back()=='\r')) keyTrim.pop_back();
bool keyRecognized = services::WalletSecurityController::isRecognizedPrivateKey(keyTrim);
bool keyIsZ = services::WalletSecurityController::classifyPrivateKey(keyTrim)
== services::WalletSecurityController::KeyKind::Shielded;
// Key validation indicator // Key validation indicator
if (import_key_input_[0] != '\0') { if (!keyTrim.empty()) {
std::string k(import_key_input_); if (keyRecognized) {
bool isZKey = (k.substr(0, 20) == "secret-extended-key-") ||
(k.length() >= 2 && k[0] == 'S' && k[1] == 'K');
bool isTKey = (k.length() >= 51 && k.length() <= 52 &&
(k[0] == '5' || k[0] == 'K' || k[0] == 'L' || k[0] == 'U'));
if (isZKey || isTKey) {
ImGui::PushFont(ui::material::Type().iconSmall()); ImGui::PushFont(ui::material::Type().iconSmall());
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), ICON_MD_CHECK_CIRCLE); ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), ICON_MD_CHECK_CIRCLE);
ImGui::PopFont(); ImGui::PopFont();
ImGui::SameLine(0, 4.0f); ImGui::SameLine(0, 4.0f);
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s",
isZKey ? "Shielded spending key" : "Transparent private key"); keyIsZ ? "Shielded spending key" : "Transparent private key");
} else { } else {
ImGui::PushFont(ui::material::Type().iconSmall()); ImGui::PushFont(ui::material::Type().iconSmall());
ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.0f, 1.0f), ICON_MD_HELP); ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.0f, 1.0f), ICON_MD_HELP);
@@ -2665,18 +2690,17 @@ void App::renderImportKeyDialog()
ImGui::Spacing(); ImGui::Spacing();
ImGui::Separator(); ImGui::Separator();
ImGui::BeginDisabled(!keyRecognized); // only a recognized Z/T key can be imported
if (ui::material::StyledButton("Import", ImVec2(btnW, 0), ui::material::resolveButtonFont(btnFont))) { if (ui::material::StyledButton("Import", ImVec2(btnW, 0), ui::material::resolveButtonFont(btnFont))) {
std::string key(import_key_input_); importPrivateKey(std::string(import_key_input_), [this](bool success, const std::string& msg) {
if (!key.empty()) { import_success_ = success;
importPrivateKey(key, [this](bool success, const std::string& msg) { import_status_ = msg;
import_success_ = success; if (success) {
import_status_ = msg; memset(import_key_input_, 0, sizeof(import_key_input_));
if (success) { }
memset(import_key_input_, 0, sizeof(import_key_input_)); });
}
});
}
} }
ImGui::EndDisabled();
ImGui::SameLine(); ImGui::SameLine();
if (ui::material::StyledButton("Close", ImVec2(btnW, 0), ui::material::resolveButtonFont(btnFont))) { if (ui::material::StyledButton("Close", ImVec2(btnW, 0), ui::material::resolveButtonFont(btnFont))) {
show_import_key_ = false; show_import_key_ = false;
@@ -2782,8 +2806,10 @@ void App::renderBackupDialog()
ImGui::Text("Backup File Path:"); ImGui::Text("Backup File Path:");
static char backup_path[512] = "dragonx-backup.txt"; static char backup_path[512] = "dragonx-backup.txt";
static bool s_backup_confirm_overwrite = false;
ImGui::SetNextItemWidth(-1); ImGui::SetNextItemWidth(-1);
ImGui::InputText("##backuppath", backup_path, sizeof(backup_path)); if (ImGui::InputText("##backuppath", backup_path, sizeof(backup_path)))
s_backup_confirm_overwrite = false; // path edited — re-check overwrite on next Save
ImGui::Spacing(); ImGui::Spacing();
@@ -2800,17 +2826,30 @@ void App::renderBackupDialog()
if (ui::material::StyledButton("Save Backup", ImVec2(btnW, 0), ui::material::resolveButtonFont(btnFont))) { if (ui::material::StyledButton("Save Backup", ImVec2(btnW, 0), ui::material::resolveButtonFont(btnFont))) {
std::string path(backup_path); std::string path(backup_path);
// Trim surrounding whitespace (a pasted path can carry a trailing newline).
while (!path.empty() && (path.front()==' '||path.front()=='\t'||path.front()=='\n'||path.front()=='\r')) path.erase(path.begin());
while (!path.empty() && (path.back()==' '||path.back()=='\t'||path.back()=='\n'||path.back()=='\r')) path.pop_back();
if (!path.empty()) { if (!path.empty()) {
backupWallet(path, [this](bool success, const std::string& msg) { std::error_code ec;
backup_success_ = success; if (!s_backup_confirm_overwrite && std::filesystem::exists(path, ec)) {
backup_status_ = msg; // Don't clobber an existing file (possibly a good earlier backup) without confirmation.
}); s_backup_confirm_overwrite = true;
backup_success_ = false;
backup_status_ = "A file already exists there — click Save Backup again to overwrite it.";
} else {
s_backup_confirm_overwrite = false;
backupWallet(path, [this](bool success, const std::string& msg) {
backup_success_ = success;
backup_status_ = msg;
});
}
} }
} }
ImGui::SameLine(); ImGui::SameLine();
if (ui::material::StyledButton("Close", ImVec2(btnW, 0), ui::material::resolveButtonFont(btnFont))) { if (ui::material::StyledButton("Close", ImVec2(btnW, 0), ui::material::resolveButtonFont(btnFont))) {
show_backup_ = false; show_backup_ = false;
backup_status_.clear(); backup_status_.clear();
s_backup_confirm_overwrite = false;
} }
ui::material::EndOverlayDialog(); ui::material::EndOverlayDialog();
@@ -3151,6 +3190,13 @@ void App::rescanBlockchain()
return; return;
} }
// Re-entrancy guard: a rescan/repair (both drive state_.sync.rescanning) or this exact task already
// running would stomp each other — a second confirm must not launch a duplicate operation.
if (state_.sync.rescanning || async_tasks_.isRunning(decision.taskName)) {
ui::Notifications::instance().warning("A blockchain maintenance operation is already in progress.");
return;
}
DEBUG_LOGF("[App] Starting blockchain rescan - stopping daemon first\n"); DEBUG_LOGF("[App] Starting blockchain rescan - stopping daemon first\n");
ui::Notifications::instance().info("Restarting daemon with -rescan flag..."); ui::Notifications::instance().info("Restarting daemon with -rescan flag...");
@@ -3193,6 +3239,11 @@ void App::repairWallet()
return; return;
} }
if (state_.sync.rescanning || async_tasks_.isRunning(decision.taskName)) {
ui::Notifications::instance().warning("A blockchain maintenance operation is already in progress.");
return;
}
DEBUG_LOGF("[App] Starting wallet repair (-zapwallettxes=2) - stopping daemon first\n"); DEBUG_LOGF("[App] Starting wallet repair (-zapwallettxes=2) - stopping daemon first\n");
ui::Notifications::instance().info("Restarting daemon with -zapwallettxes=2 (wallet repair)..."); ui::Notifications::instance().info("Restarting daemon with -zapwallettxes=2 (wallet repair)...");
@@ -3234,6 +3285,11 @@ void App::reinstallBundledDaemon()
return; return;
} }
if (async_tasks_.isRunning("reinstall-daemon")) {
ui::Notifications::instance().warning("The daemon reinstall is already in progress.");
return;
}
DEBUG_LOGF("[App] Reinstalling bundled daemon binary — stopping daemon first\n"); DEBUG_LOGF("[App] Reinstalling bundled daemon binary — stopping daemon first\n");
ui::Notifications::instance().info("Installing bundled daemon — the node will stop, update, and restart..."); ui::Notifications::instance().info("Installing bundled daemon — the node will stop, update, and restart...");
@@ -3273,6 +3329,11 @@ void App::deleteBlockchainData()
return; return;
} }
if (state_.sync.rescanning || async_tasks_.isRunning(decision.taskName)) {
ui::Notifications::instance().warning("A blockchain maintenance operation is already in progress.");
return;
}
DEBUG_LOGF("[App] Deleting blockchain data - stopping daemon first\n"); DEBUG_LOGF("[App] Deleting blockchain data - stopping daemon first\n");
ui::Notifications::instance().info("Stopping daemon and deleting blockchain data..."); ui::Notifications::instance().info("Stopping daemon and deleting blockchain data...");

View File

@@ -269,7 +269,8 @@ public:
// Key export/import // Key export/import
void exportPrivateKey(const std::string& address, std::function<void(const std::string&)> callback); void exportPrivateKey(const std::string& address, std::function<void(const std::string&)> callback);
void exportAllKeys(std::function<void(const std::string&)> callback); // callback receives (keys, exportedCount, totalAddresses) so callers can detect a keyless/partial export.
void exportAllKeys(std::function<void(const std::string&, int, int)> callback);
void importPrivateKey(const std::string& key, std::function<void(bool, const std::string&)> callback); void importPrivateKey(const std::string& key, std::function<void(bool, const std::string&)> callback);
// Wallet backup // Wallet backup

View File

@@ -2323,10 +2323,10 @@ void App::exportPrivateKey(const std::string& address, std::function<void(const
}); });
} }
void App::exportAllKeys(std::function<void(const std::string&)> callback) void App::exportAllKeys(std::function<void(const std::string&, int, int)> callback)
{ {
if (!state_.connected || !rpc_) { if (!state_.connected || !rpc_) {
if (callback) callback(""); if (callback) callback("", 0, 0);
return; return;
} }
@@ -2334,6 +2334,7 @@ void App::exportAllKeys(std::function<void(const std::string&)> callback)
auto keys_result = std::make_shared<std::string>(); auto keys_result = std::make_shared<std::string>();
auto pending = std::make_shared<int>(0); auto pending = std::make_shared<int>(0);
auto total = std::make_shared<int>(0); auto total = std::make_shared<int>(0);
auto exported = std::make_shared<int>(0); // keys actually retrieved (vs. failed/locked)
// First get all addresses // First get all addresses
auto all_addresses = std::make_shared<std::vector<std::string>>(); auto all_addresses = std::make_shared<std::vector<std::string>>();
@@ -2351,7 +2352,7 @@ void App::exportAllKeys(std::function<void(const std::string&)> callback)
*pending = *total; *pending = *total;
if (*total == 0) { if (*total == 0) {
if (callback) callback("# No addresses to export\n"); if (callback) callback("# No addresses to export\n", 0, 0);
return; return;
} }
@@ -2359,26 +2360,40 @@ void App::exportAllKeys(std::function<void(const std::string&)> callback)
*keys_result += "# WARNING: Keep this file secure! Anyone with these keys can spend your coins!\n\n"; *keys_result += "# WARNING: Keep this file secure! Anyone with these keys can spend your coins!\n\n";
for (const auto& addr : *all_addresses) { for (const auto& addr : *all_addresses) {
exportPrivateKey(addr, [keys_result, pending, total, callback, addr](const std::string& key) { exportPrivateKey(addr, [keys_result, pending, total, exported, callback, addr](const std::string& key) {
if (!key.empty()) { if (!key.empty()) {
*keys_result += "# " + addr + "\n"; *keys_result += "# " + addr + "\n";
*keys_result += key + "\n\n"; *keys_result += key + "\n\n";
(*exported)++;
} }
(*pending)--; (*pending)--;
if (*pending == 0 && callback) { if (*pending == 0 && callback) {
callback(*keys_result); callback(*keys_result, *exported, *total);
} }
}); });
} }
} }
void App::importPrivateKey(const std::string& key, std::function<void(bool, const std::string&)> callback) void App::importPrivateKey(const std::string& rawKey, std::function<void(bool, const std::string&)> callback)
{ {
if (!state_.connected || !rpc_ || !worker_) { if (!state_.connected || !rpc_ || !worker_) {
if (callback) callback(false, "Not connected"); if (callback) callback(false, "Not connected");
return; return;
} }
// Trim whitespace/newlines — manual entry doesn't go through the dialog's Paste trimmer, and a
// stray character makes the daemon reject the key with a cryptic error.
std::string key(rawKey);
while (!key.empty() && (key.front()==' '||key.front()=='\t'||key.front()=='\n'||key.front()=='\r')) key.erase(key.begin());
while (!key.empty() && (key.back()==' '||key.back()=='\t'||key.back()=='\n'||key.back()=='\r')) key.pop_back();
// Reject anything that doesn't look like a Z/T private key before handing it to the daemon (the
// dialog's indicator and this guard now share isRecognizedPrivateKey, so they can't disagree).
if (!services::WalletSecurityController::isRecognizedPrivateKey(key)) {
if (callback) callback(false, "Unrecognized private-key format.");
return;
}
const bool shielded = services::WalletSecurityController::classifyPrivateKey(key) const bool shielded = services::WalletSecurityController::classifyPrivateKey(key)
== services::WalletSecurityController::KeyKind::Shielded; == services::WalletSecurityController::KeyKind::Shielded;
// Run on the worker thread — import requests a full rescan (rescan=true), so the // Run on the worker thread — import requests a full rescan (rescan=true), so the
@@ -2413,25 +2428,33 @@ void App::backupWallet(const std::string& destination, std::function<void(bool,
return; return;
} }
// Use z_exportwallet or similar to export all keys // Export all keys and save to file.
// For now, we'll use exportAllKeys and save to file exportAllKeys([destination, callback](const std::string& keys, int exported, int total) {
exportAllKeys([destination, callback](const std::string& keys) { // NEVER write (or report success for) a keyless backup — the usual cause is an encrypted+locked
if (keys.empty()) { // wallet, and a "success" here would leave the user believing they have a recovery file when the
if (callback) callback(false, "Failed to export keys"); // file contains only the header comments and ZERO keys.
if (exported == 0) {
if (callback) callback(false, total == 0
? "No addresses to back up."
: "Backup failed: 0 of " + std::to_string(total) + " keys could be exported. "
"Unlock the wallet (if encrypted) and try again.");
return; return;
} }
// Write to file
std::ofstream file(destination); std::ofstream file(destination);
if (!file.is_open()) { if (!file.is_open()) {
if (callback) callback(false, "Could not open file: " + destination); if (callback) callback(false, "Could not open file: " + destination);
return; return;
} }
file << keys; file << keys;
file.close(); file.close();
if (callback) callback(true, "Wallet backup saved to: " + destination); std::string msg = "Wallet backup saved to " + destination + ""
+ std::to_string(exported) + " of " + std::to_string(total) + " keys.";
if (exported < total)
msg += " NOTE: some addresses had no spending key or the wallet is locked — this backup is INCOMPLETE.";
// A partial backup is a real risk for recovery, so surface it as a warning (not a green success).
if (callback) callback(exported == total, msg);
}); });
} }

View File

@@ -1285,10 +1285,24 @@ void App::renderFirstRunWizard() {
cy += captionFont->LegacySize + 6.0f * dp; cy += captionFont->LegacySize + 6.0f * dp;
} }
// Warn + block if the passphrase has leading/trailing whitespace. Silently trimming it would
// change the passphrase the user believes they set and lock them out on the next unlock.
bool passEdgeSpace = false;
if (size_t pl = strlen(encrypt_pass_buf_)) {
char a = encrypt_pass_buf_[0], b = encrypt_pass_buf_[pl - 1];
passEdgeSpace = (a == ' ' || a == '\t' || b == ' ' || b == '\t');
}
if (passEdgeSpace) {
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy),
ui::material::Error(), "Passphrase has leading/trailing spaces — remove them");
cy += captionFont->LegacySize + 6.0f * dp;
}
// Buttons // Buttons
{ {
bool passValid = strlen(encrypt_pass_buf_) >= 8 && bool passValid = strlen(encrypt_pass_buf_) >= 8 &&
strcmp(encrypt_pass_buf_, encrypt_confirm_buf_) == 0; strcmp(encrypt_pass_buf_, encrypt_confirm_buf_) == 0 &&
!passEdgeSpace;
// PIN is optional: if entered, must be valid + confirmed // PIN is optional: if entered, must be valid + confirmed
std::string pinStr(wizard_pin_buf_); std::string pinStr(wizard_pin_buf_);
bool pinEntered = !pinStr.empty(); bool pinEntered = !pinStr.empty();
@@ -1342,16 +1356,24 @@ void App::renderFirstRunWizard() {
ImGui::SetCursorScreenPos(ImVec2(bx + encBtnW + 12.0f * dp, cy)); ImGui::SetCursorScreenPos(ImVec2(bx + encBtnW + 12.0f * dp, cy));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp); ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
if (ui::material::TactileButton("Skip##enc", ImVec2(skipW2, btnH2))) { if (ui::material::TactileButton("Skip##enc", ImVec2(skipW2, btnH2))) {
wizard_phase_ = WizardPhase::Done; static bool s_skipEncConfirm = false;
settings_->setWizardCompleted(true); if (!s_skipEncConfirm) {
settings_->save(); // Skipping stores private keys UNENCRYPTED — require a confirming second click.
if (!isEmbeddedDaemonRunning() && isUsingEmbeddedDaemon()) { s_skipEncConfirm = true;
if (!startEmbeddedDaemon()) { encrypt_status_ = "Continue WITHOUT encryption? Keys will be stored unencrypted — click Skip again to confirm.";
ui::Notifications::instance().warning( } else {
TR("wizard_daemon_start_failed")); s_skipEncConfirm = false;
wizard_phase_ = WizardPhase::Done;
settings_->setWizardCompleted(true);
settings_->save();
if (!isEmbeddedDaemonRunning() && isUsingEmbeddedDaemon()) {
if (!startEmbeddedDaemon()) {
ui::Notifications::instance().warning(
TR("wizard_daemon_start_failed"));
}
} }
tryConnect();
} }
tryConnect();
} }
ImGui::PopStyleVar(); ImGui::PopStyleVar();
cy += btnH2; cy += btnH2;

View File

@@ -97,7 +97,22 @@ WalletSecurityController::KeyKind WalletSecurityController::classifyAddress(cons
WalletSecurityController::KeyKind WalletSecurityController::classifyPrivateKey(const std::string& key) WalletSecurityController::KeyKind WalletSecurityController::classifyPrivateKey(const std::string& key)
{ {
return !key.empty() && key[0] == 's' ? KeyKind::Shielded : KeyKind::Transparent; // Shielded: Sapling extended spending key (secret-extended-key-...) or legacy Sprout (SK...).
// (The old `key[0]=='s'` test misrouted an uppercase "SK..." shielded key to the transparent RPC.)
if (key.rfind("secret-extended-key-", 0) == 0) return KeyKind::Shielded;
if (key.size() >= 2 && key[0] == 'S' && key[1] == 'K') return KeyKind::Shielded;
if (!key.empty() && key[0] == 's') return KeyKind::Shielded;
return KeyKind::Transparent;
}
bool WalletSecurityController::isRecognizedPrivateKey(const std::string& key)
{
if (key.rfind("secret-extended-key-", 0) == 0) return true; // Sapling z spending key
if (key.size() >= 2 && key[0] == 'S' && key[1] == 'K') return true; // Sprout z spending key
// Transparent WIF: base58, ~51-52 chars, common version prefixes.
if (key.size() >= 51 && key.size() <= 52 &&
(key[0] == '5' || key[0] == 'K' || key[0] == 'L' || key[0] == 'U')) return true;
return false;
} }
const char* WalletSecurityController::importSuccessMessage(KeyKind kind) const char* WalletSecurityController::importSuccessMessage(KeyKind kind)

View File

@@ -74,6 +74,9 @@ public:
std::size_t minLength = 4); std::size_t minLength = 4);
static KeyKind classifyAddress(const std::string& address); static KeyKind classifyAddress(const std::string& address);
static KeyKind classifyPrivateKey(const std::string& key); static KeyKind classifyPrivateKey(const std::string& key);
// True if `key` looks like a recognized Z (Sapling/Sprout spending) or T (WIF) private key.
// Single source of truth for the import dialog's indicator AND its submit guard.
static bool isRecognizedPrivateKey(const std::string& key);
static const char* importSuccessMessage(KeyKind kind); static const char* importSuccessMessage(KeyKind kind);
static std::string decryptExportFileName(std::uint64_t timestampSeconds); static std::string decryptExportFileName(std::uint64_t timestampSeconds);
static void secureClear(std::string& value); static void secureClear(std::string& value);

View File

@@ -1741,10 +1741,17 @@ void RenderSettingsPage(App* app) {
ImGuiInputTextFlags_Password); ImGuiInputTextFlags_Password);
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y)); ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
if (TactileButton(TrId("lite_import", "LiteImportKeyBtn").c_str(), ImVec2(0, 0), S.resolveFont("button"))) { if (TactileButton(TrId("lite_import", "LiteImportKeyBtn").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
const auto r = app->liteWallet()->importKey(s_settingsState.lite_import_key); std::string liteKey(s_settingsState.lite_import_key);
sodium_memzero(s_settingsState.lite_import_key, sizeof(s_settingsState.lite_import_key)); while (!liteKey.empty() && (liteKey.front()==' '||liteKey.front()=='\t'||liteKey.front()=='\n'||liteKey.front()=='\r')) liteKey.erase(liteKey.begin());
s_settingsState.lite_backup_status = while (!liteKey.empty() && (liteKey.back()==' '||liteKey.back()=='\t'||liteKey.back()=='\n'||liteKey.back()=='\r')) liteKey.pop_back();
r.ok ? TR("lite_key_imported") : r.error; if (liteKey.empty()) {
s_settingsState.lite_backup_status = "Enter a private key to import.";
} else {
const auto r = app->liteWallet()->importKey(liteKey);
sodium_memzero(s_settingsState.lite_import_key, sizeof(s_settingsState.lite_import_key));
s_settingsState.lite_backup_status =
r.ok ? TR("lite_key_imported") : r.error;
}
} }
if (!s_settingsState.lite_backup_status.empty()) { if (!s_settingsState.lite_backup_status.empty()) {

View File

@@ -129,7 +129,13 @@ void AddressBookDialog::render(App* app)
const char* primaryLabel = isEdit ? TR("save") : TR("add"); const char* primaryLabel = isEdit ? TR("save") : TR("add");
if (material::StyledButton(primaryLabel, ImVec2(actionW, 0), S.resolveFont(actionBtn.font))) { if (material::StyledButton(primaryLabel, ImVec2(actionW, 0), S.resolveFont(actionBtn.font))) {
data::AddressBookEntry entry(s_edit_label, s_edit_address, s_edit_notes); // Trim the label/address (a pasted address often carries a trailing newline); keep notes as-is.
auto trimAB = [](std::string s) {
while (!s.empty() && (s.front()==' '||s.front()=='\t'||s.front()=='\n'||s.front()=='\r')) s.erase(s.begin());
while (!s.empty() && (s.back()==' '||s.back()=='\t'||s.back()=='\n'||s.back()=='\r')) s.pop_back();
return s;
};
data::AddressBookEntry entry(trimAB(s_edit_label), trimAB(s_edit_address), s_edit_notes);
if (isEdit) { if (isEdit) {
if (getAddressBook().updateEntry(s_selected_index, entry)) { if (getAddressBook().updateEntry(s_selected_index, entry)) {
Notifications::instance().success(TR("address_book_updated")); Notifications::instance().success(TR("address_book_updated"));
@@ -183,11 +189,19 @@ void AddressBookDialog::render(App* app)
ImGui::SameLine(); ImGui::SameLine();
static int s_confirmDeleteIdx = -1;
if (material::StyledButton(TR("delete"), ImVec2(0,0), S.resolveFont(actionBtn.font))) { if (material::StyledButton(TR("delete"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
if (has_selection) { if (has_selection) {
book.removeEntry(s_selected_index); if (s_confirmDeleteIdx == s_selected_index) {
s_selected_index = -1; book.removeEntry(s_selected_index);
Notifications::instance().success(TR("address_book_deleted")); s_selected_index = -1;
s_confirmDeleteIdx = -1;
Notifications::instance().success(TR("address_book_deleted"));
} else {
// Require a second click to confirm (no undo for a removed contact).
s_confirmDeleteIdx = s_selected_index;
Notifications::instance().warning("Click Delete again to remove this entry.");
}
} }
} }

View File

@@ -786,7 +786,7 @@ void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float
ImGuiWindowFlags_NoBackground); ImGuiWindowFlags_NoBackground);
const auto& txs = state.transactions; const auto& txs = state.transactions;
int count = (int)txs.size(); int count = std::min(4, (int)txs.size()); // show only the 4 most recent (state.transactions is newest-first)
if (count == 0) { if (count == 0) {
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_transactions_yet")); Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_transactions_yet"));

View File

@@ -30,8 +30,15 @@ class BootstrapDownloadDialog {
public: public:
static void show(App* app) { static void show(App* app) {
if (!app || !app->supportsFullNodeLifecycleActions()) return; if (!app || !app->supportsFullNodeLifecycleActions()) return;
s_open = true;
s_app = app; s_app = app;
// If a download is already in flight, RE-ATTACH to it (just reopen showing progress) instead of
// resetting state and orphaning the running worker / restarting an unverified download.
if (app->isBootstrapDownloading()) {
s_open = true;
if (s_state == State::Confirm) s_state = State::Downloading;
return;
}
s_open = true;
s_state = State::Confirm; s_state = State::Confirm;
s_bootstrap.reset(); s_bootstrap.reset();
s_errorMsg.clear(); s_errorMsg.clear();

View File

@@ -48,12 +48,19 @@ void FullNodeConsoleExecutor::submit(const std::string& cmd)
} }
auto call = BuildConsoleRpcCall(cmd); auto call = BuildConsoleRpcCall(cmd);
if (!call.valid) return; if (!call.valid) {
if (!call.error.empty()) {
std::lock_guard<std::mutex> lk(results_mutex_);
results_.push_back({call.error, true});
}
return;
}
const std::string method = call.method; const std::string method = call.method;
const nlohmann::json params = call.params; const nlohmann::json params = call.params;
rpc::RPCWorker* worker = app_->consoleWorker(); rpc::RPCWorker* worker = app_->consoleWorker();
if (worker) { if (worker) {
in_flight_.fetch_add(1); // gates busy() so the input is disabled until this returns
worker->post([rpc, method, params, this]() -> rpc::RPCWorker::MainCb { worker->post([rpc, method, params, this]() -> rpc::RPCWorker::MainCb {
std::string result_str; std::string result_str;
bool is_error = false; bool is_error = false;
@@ -65,6 +72,7 @@ void FullNodeConsoleExecutor::submit(const std::string& cmd)
is_error = true; is_error = true;
} }
return [this, result_str, is_error]() { return [this, result_str, is_error]() {
in_flight_.fetch_sub(1);
std::lock_guard<std::mutex> lk(results_mutex_); std::lock_guard<std::mutex> lk(results_mutex_);
results_.push_back({result_str, is_error}); results_.push_back({result_str, is_error});
}; };
@@ -190,7 +198,12 @@ ConsoleStatusLine FullNodeConsoleExecutor::toolbarStatus() const
{ {
ConsoleStatusLine s; ConsoleStatusLine s;
daemon::EmbeddedDaemon* d = app_->consoleDaemon(); daemon::EmbeddedDaemon* d = app_->consoleDaemon();
if (!d) return s; // empty text -> toolbar shows the generic "no daemon" label if (!d) {
// No wallet-managed daemon, but a daemon started externally (before the wallet) is still
// reachable over RPC and accepts commands — report it as running rather than "no daemon".
if (app_->isConnected()) { s.text = TR("console_status_running"); s.color = Success(); }
return s; // otherwise empty text -> toolbar shows the generic "no daemon" label
}
using DState = daemon::EmbeddedDaemon::State; using DState = daemon::EmbeddedDaemon::State;
switch (d->getState()) { switch (d->getState()) {
case DState::Stopped: s.text = TR("console_status_stopped"); s.color = IM_COL32(150,150,150,255); break; case DState::Stopped: s.text = TR("console_status_stopped"); s.color = IM_COL32(150,150,150,255); break;

View File

@@ -12,6 +12,7 @@
#include "console_channel.h" #include "console_channel.h"
#include "imgui.h" #include "imgui.h"
#include <atomic>
#include <cstdint> #include <cstdint>
#include <deque> #include <deque>
#include <functional> #include <functional>
@@ -91,9 +92,12 @@ public:
ConsoleLogFilterCaps logFilterCaps() const override { return {true, true, true, true}; } ConsoleLogFilterCaps logFilterCaps() const override { return {true, true, true, true}; }
void printHelp(const ConsoleAddLineFn& add) override; void printHelp(const ConsoleAddLineFn& add) override;
ConsoleStatusLine toolbarStatus() const override; ConsoleStatusLine toolbarStatus() const override;
// A command is in flight on the RPC worker — lets the UI disable the input (no queued pile-up).
bool busy() const override { return in_flight_.load() > 0; }
private: private:
App* app_; App* app_;
std::atomic<int> in_flight_{0};
size_t last_daemon_output_size_ = 0; size_t last_daemon_output_size_ = 0;
size_t last_xmrig_output_size_ = 0; size_t last_xmrig_output_size_ = 0;
int last_daemon_state_ = -1; // daemon::EmbeddedDaemon::State as int int last_daemon_state_ = -1; // daemon::EmbeddedDaemon::State as int

View File

@@ -170,6 +170,10 @@ ConsoleRpcCall BuildConsoleRpcCall(const std::string& command)
call.params.push_back(parsed); call.params.push_back(parsed);
continue; continue;
} }
// Malformed JSON — fail the whole command instead of silently sending it as a string.
call.valid = false;
call.error = "Argument " + std::to_string(argIndex) + " is not valid JSON: " + arg;
return call;
} }
if (arg == "true") { if (arg == "true") {

View File

@@ -17,6 +17,7 @@ struct ConsoleRpcCall {
bool valid = false; bool valid = false;
std::string method; std::string method;
nlohmann::json params = nlohmann::json::array(); nlohmann::json params = nlohmann::json::array();
std::string error; // set (with valid=false) when an argument fails to parse
}; };
enum class ConsoleResultLineRole { enum class ConsoleResultLineRole {

View File

@@ -1156,7 +1156,13 @@ void ConsoleTab::renderInput(ConsoleCommandExecutor& exec)
// the darkened output panel above. // the darkened output panel above.
{ {
int darkA = static_cast<int>(schema::UI().drawElement("tabs.console", "bg-darken-alpha").sizeOr(110.0f)); int darkA = static_cast<int>(schema::UI().drawElement("tabs.console", "bg-darken-alpha").sizeOr(110.0f));
dlIn->AddRectFilled(inMin, inMax, IM_COL32(0, 0, 0, darkA), Layout::glassRounding()); const bool barLight = IsLightTheme();
// Match the output panel: near-white terminal surface on light skins, dark on dark, with a
// 1px glass-rim outline along the bar's own edges (so the outline hugs the input, not inset).
dlIn->AddRectFilled(inMin, inMax, barLight ? IM_COL32(255, 255, 255, 205) : IM_COL32(0, 0, 0, darkA),
Layout::glassRounding());
dlIn->AddRect(inMin, inMax, barLight ? IM_COL32(0, 0, 0, 45) : IM_COL32(255, 255, 255, 35),
Layout::glassRounding(), 0, 1.0f);
} }
// Center content vertically within glass panel // Center content vertically within glass panel
@@ -1223,10 +1229,12 @@ void ConsoleTab::renderInput(ConsoleCommandExecutor& exec)
return 0; return 0;
}; };
// Blend the field into the dark terminal bar: transparent frame, subtle hover/active. // Transparent frame with a subtle hover/active tint (the outline lives on the terminal bar above,
// hugging the input's edges). Colors are theme-aware so they read on the light-skin console too.
const bool inputLight = material::IsLightTheme();
ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32(0, 0, 0, 0)); ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, IM_COL32(255, 255, 255, 12)); ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, inputLight ? IM_COL32(0, 0, 0, 10) : IM_COL32(255, 255, 255, 12));
ImGui::PushStyleColor(ImGuiCol_FrameBgActive, IM_COL32(255, 255, 255, 18)); ImGui::PushStyleColor(ImGuiCol_FrameBgActive, inputLight ? IM_COL32(0, 0, 0, 16) : IM_COL32(255, 255, 255, 18));
const bool busy = exec.busy(); const bool busy = exec.busy();
if (busy) ImGui::BeginDisabled(); if (busy) ImGui::BeginDisabled();
@@ -1243,6 +1251,25 @@ void ConsoleTab::renderInput(ConsoleCommandExecutor& exec)
} }
} }
if (busy) ImGui::EndDisabled(); if (busy) ImGui::EndDisabled();
// Blinking terminal caret at the end of the current input. Shown when the field isn't actively
// being edited (ImGui draws its own caret while focused), so the console always reads as a live
// prompt. Monospace font => caret X = frame-padding + charWidth * length.
if (!busy && !ImGui::IsItemActive()) {
if (std::fmod(ImGui::GetTime(), 1.06) < 0.53) {
ImFont* mf = Type().mono();
float charW = mf->CalcTextSizeA(mf->LegacySize, FLT_MAX, 0, "M").x;
ImVec2 itMin = ImGui::GetItemRectMin();
ImVec2 itMax = ImGui::GetItemRectMax();
float caretW = std::max(2.0f, 2.0f * Layout::dpiScale());
float caretX = itMin.x + ImGui::GetStyle().FramePadding.x + charW * (float)strlen(input_buffer_);
caretX = std::min(caretX, itMax.x - ImGui::GetStyle().FramePadding.x - caretW);
float pad = (itMax.y - itMin.y) * 0.18f;
ImU32 caretCol = inputLight ? IM_COL32(0, 0, 0, 210) : IM_COL32(255, 255, 255, 210);
dlIn->AddRectFilled(ImVec2(caretX, itMin.y + pad), ImVec2(caretX + caretW, itMax.y - pad), caretCol);
}
}
ImGui::PopStyleColor(3); ImGui::PopStyleColor(3);
ImGui::PopItemWidth(); ImGui::PopItemWidth();
@@ -1271,6 +1298,9 @@ bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::s
std::transform(first.begin(), first.end(), first.begin(), std::transform(first.begin(), first.end(), first.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); }); [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
} }
// 'stop' shuts down the node — require a confirming second 'stop'; any other command clears the pending state.
static bool stopConfirmPending = false;
if (first != "stop") stopConfirmPending = false;
auto add = [this](const std::string& l, ConsoleChannel c) { addLine(l, c); }; auto add = [this](const std::string& l, ConsoleChannel c) { addLine(l, c); };
if (first == "clear" || first == "cls") { if (first == "clear" || first == "cls") {
// View-only clear — NEVER forwarded (the lite backend's `clear` wipes tx history). // View-only clear — NEVER forwarded (the lite backend's `clear` wipes tx history).
@@ -1280,6 +1310,16 @@ bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::s
exec.printHelp(add); exec.printHelp(add);
} else if (first == "quit" || first == "exit") { } else if (first == "quit" || first == "exit") {
addLine(TR("console_quit_note"), ConsoleChannel::Info); addLine(TR("console_quit_note"), ConsoleChannel::Info);
} else if (first == "stop") {
if (!stopConfirmPending) {
stopConfirmPending = true;
addLine("'stop' will shut down the node and disconnect the wallet. Type 'stop' again to confirm.",
ConsoleChannel::Warning);
} else {
stopConfirmPending = false;
if (!exec.isReady()) addLine(TR("console_not_connected"), ConsoleChannel::Error);
else exec.submit(cmd);
}
} else if (!exec.isReady()) { } else if (!exec.isReady()) {
addLine(TR("console_not_connected"), ConsoleChannel::Error); addLine(TR("console_not_connected"), ConsoleChannel::Error);
} else { } else {

View File

@@ -62,12 +62,14 @@ public:
} }
using namespace material; using namespace material;
const float dp = Layout::dpiScale(); const float dp = Layout::dpiScale();
const auto p = s_updater->getProgress();
// No window X during an active download/verify/extract — closing would orphan/block on the
// worker; the in-dialog Cancel is the intended way to abort.
const bool active = (p.state == St::Downloading || p.state == St::Verifying || p.state == St::Extracting);
OverlayDialogSpec ov; OverlayDialogSpec ov;
ov.title = TR("daemon_update_title"); ov.p_open = &s_open; ov.title = TR("daemon_update_title"); ov.p_open = active ? nullptr : &s_open;
ov.style = OverlayStyle::BlurFloat; ov.cardWidth = 480.0f; ov.cardBottomViewportRatio = 0.94f; ov.style = OverlayStyle::BlurFloat; ov.cardWidth = 480.0f; ov.cardBottomViewportRatio = 0.94f;
if (BeginOverlayDialog(ov)) { if (BeginOverlayDialog(ov)) {
const auto p = s_updater->getProgress();
using St = util::DaemonUpdater::State;
switch (p.state) { switch (p.state) {
case St::Checking: renderChecking(dp, p); break; case St::Checking: renderChecking(dp, p); break;
case St::UpToDate: case St::UpToDate:

View File

@@ -177,9 +177,9 @@ void ExportAllKeysDialog::render(App* app)
if (result.is_string()) { if (result.is_string()) {
keys += "# Address: " + addr + "\n"; keys += "# Address: " + addr + "\n";
keys += result.get<std::string>() + "\n\n"; keys += result.get<std::string>() + "\n\n";
exported++; // count only real successes (locked/failed keys don't count)
} }
} catch (...) {} } catch (...) {}
exported++;
} }
} }
@@ -193,17 +193,18 @@ void ExportAllKeysDialog::render(App* app)
if (result.is_string()) { if (result.is_string()) {
keys += "# Address: " + addr + "\n"; keys += "# Address: " + addr + "\n";
keys += result.get<std::string>() + "\n\n"; keys += result.get<std::string>() + "\n\n";
exported++; // count only real successes (locked/failed keys don't count)
} }
} catch (...) {} } catch (...) {}
exported++;
} }
} }
// Save to file (still on worker thread) // Only write a file if we actually exported keys — a keyless file would be a
// misleading "export" (the usual cause of 0 keys is an encrypted+locked wallet).
std::string configDir = util::Platform::getConfigDir(); std::string configDir = util::Platform::getConfigDir();
std::string filepath = configDir + "/" + filename; std::string filepath = configDir + "/" + filename;
bool writeOk = false; bool writeOk = false;
{ if (exported > 0) {
std::ofstream file(filepath); std::ofstream file(filepath);
if (file.is_open()) { if (file.is_open()) {
file << keys; file << keys;
@@ -215,12 +216,22 @@ void ExportAllKeysDialog::render(App* app)
return [exported, total, filepath, writeOk]() { return [exported, total, filepath, writeOk]() {
s_exported_count = exported; s_exported_count = exported;
s_exporting = false; s_exporting = false;
if (writeOk) { if (exported == 0) {
s_status = "Exported to: " + filepath; s_status = "No keys exported (0 of " + std::to_string(total) +
Notifications::instance().success(TR("export_keys_success"), 5.0f); ") — unlock the wallet (if encrypted) and try again.";
} else { Notifications::instance().error("No keys could be exported — is the wallet unlocked?");
} else if (!writeOk) {
s_status = "Failed to write file"; s_status = "Failed to write file";
Notifications::instance().error("Failed to save key file"); Notifications::instance().error("Failed to save key file");
} else if (exported < total) {
s_status = "Exported " + std::to_string(exported) + " of " +
std::to_string(total) + " keys to: " + filepath +
" (INCOMPLETE — some addresses had no spending key or the wallet is locked)";
Notifications::instance().warning("Partial export: " + std::to_string(exported) +
" of " + std::to_string(total) + " keys");
} else {
s_status = "Exported to: " + filepath;
Notifications::instance().success(TR("export_keys_success"), 5.0f);
} }
}; };
}); });

View File

@@ -690,8 +690,12 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
// If pool mining is still shutting down after switching to solo, // If pool mining is still shutting down after switching to solo,
// keep the button enabled so user can stop it. // keep the button enabled so user can stop it.
bool poolStillRunning = !s_pool_mode && state.pool_mining.xmrig_running; bool poolStillRunning = !s_pool_mode && state.pool_mining.xmrig_running;
// Can't start pool mining without a payout address (blank for a new wallet with no z-address);
// only blocks starting — stopping a running miner stays enabled.
bool poolNeedsPayout = s_pool_mode && !state.pool_mining.xmrig_running &&
std::string(s_pool_worker).empty();
bool disabled = s_pool_mode bool disabled = s_pool_mode
? (isToggling || poolBlockedBySolo) ? (isToggling || poolBlockedBySolo || poolNeedsPayout)
: (poolStillRunning ? false : (!app->isConnected() || isToggling || isSyncing)); : (poolStillRunning ? false : (!app->isConnected() || isToggling || isSyncing));
// Glass panel background with state-dependent tint // Glass panel background with state-dependent tint
@@ -821,6 +825,8 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
material::Tooltip(TR("mining_syncing_tooltip"), state.sync.verification_progress * 100.0); material::Tooltip(TR("mining_syncing_tooltip"), state.sync.verification_progress * 100.0);
else if (poolBlockedBySolo) else if (poolBlockedBySolo)
material::Tooltip("%s", TR("mining_stop_solo_for_pool")); material::Tooltip("%s", TR("mining_stop_solo_for_pool"));
else if (poolNeedsPayout)
material::Tooltip("%s", "Enter a payout address first (generate a Z address)");
else else
material::Tooltip("%s", isMiningActive ? TR("stop_mining") : TR("start_mining")); material::Tooltip("%s", isMiningActive ? TR("stop_mining") : TR("start_mining"));
} }

View File

@@ -315,7 +315,7 @@ static void RenderLeftPoolCard(App* app, const WalletState& state, ImDrawList* d
char right[64]; char right[64];
std::string hrStr = haveHr ? FormatHashrate(it->second.hashrateHs) : std::string(""); std::string hrStr = haveHr ? FormatHashrate(it->second.hashrateHs) : std::string("");
snprintf(right, sizeof(right), "%s %.0f%%", hrStr.c_str(), kp.feePercent); snprintf(right, sizeof(right), "%s %.0f%% fee", hrStr.c_str(), kp.feePercent);
ImVec2 rSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, right); ImVec2 rSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, right);
cdl->AddText(capFont, capFont->LegacySize, cdl->AddText(capFont, capFont->LegacySize,
ImVec2(rMax.x - rSz.x - 6 * dp, textY), OnSurfaceMedium(), right); ImVec2(rMax.x - rSz.x - 6 * dp, textY), OnSurfaceMedium(), right);
@@ -374,7 +374,8 @@ void RenderMiningStats(const WalletState& state, const MiningInfo& mining,
float cardH = std::max(leftContentH, statRowH + chartBudgetH + pad); float cardH = std::max(leftContentH, statRowH + chartBudgetH + pad);
ImVec2 cardMin = ImGui::GetCursorScreenPos(); ImVec2 cardMin = ImGui::GetCursorScreenPos();
float leftW = std::clamp(availWidth * 0.25f, 180.0f * dp, availWidth * 0.45f); // A bit wider than the old 25%/180dp so the pool rows fit the "<hashrate> N% fee" text.
float leftW = std::clamp(availWidth * 0.30f, 210.0f * dp, availWidth * 0.45f);
float colGap = gap; float colGap = gap;
float rightW = availWidth - leftW - colGap; float rightW = availWidth - leftW - colGap;

View File

@@ -199,8 +199,19 @@ static void RenderMiningTabContent(App* app)
// Persist pool settings when dirty and no field is active // Persist pool settings when dirty and no field is active
if (s_pool_settings_dirty && !ImGui::IsAnyItemActive()) { if (s_pool_settings_dirty && !ImGui::IsAnyItemActive()) {
app->settings()->setPoolUrl(s_pool_url); // Trim whitespace/newlines (a pasted URL or payout address often carries a trailing newline)
app->settings()->setPoolWorker(s_pool_worker); // before persisting and feeding it to xmrig; write the trimmed value back so the field agrees.
auto trimmed = [](const char* b) {
std::string s(b);
while (!s.empty() && (s.front()==' '||s.front()=='\t'||s.front()=='\n'||s.front()=='\r')) s.erase(s.begin());
while (!s.empty() && (s.back()==' '||s.back()=='\t'||s.back()=='\n'||s.back()=='\r')) s.pop_back();
return s;
};
std::string poolUrl = trimmed(s_pool_url), poolWorker = trimmed(s_pool_worker);
snprintf(s_pool_url, sizeof(s_pool_url), "%s", poolUrl.c_str());
snprintf(s_pool_worker, sizeof(s_pool_worker), "%s", poolWorker.c_str());
app->settings()->setPoolUrl(poolUrl);
app->settings()->setPoolWorker(poolWorker);
app->settings()->save(); app->settings()->save();
s_pool_settings_dirty = false; s_pool_settings_dirty = false;

View File

@@ -178,7 +178,13 @@ void RenderLiteNetworkTab(App* app)
ImGui::InputTextWithHint("##LiteAddLabel", TR("lite_net_add_label_hint"), s_addLabel, sizeof(s_addLabel)); ImGui::InputTextWithHint("##LiteAddLabel", TR("lite_net_add_label_hint"), s_addLabel, sizeof(s_addLabel));
ImGui::SameLine(); ImGui::SameLine();
if (TactileButton(TR("lite_net_add"), ImVec2(addBtnW, 0))) { if (TactileButton(TR("lite_net_add"), ImVec2(addBtnW, 0))) {
std::string url = s_addUrl; auto trimStr = [](std::string s) {
while (!s.empty() && (s.front()==' '||s.front()=='\t'||s.front()=='\n'||s.front()=='\r')) s.erase(s.begin());
while (!s.empty() && (s.back()==' '||s.back()=='\t'||s.back()=='\n'||s.back()=='\r')) s.pop_back();
return s;
};
std::string url = trimStr(s_addUrl);
std::string addLabel = trimStr(s_addLabel);
if (!wallet::isLiteServerUrlUsable(url)) { if (!wallet::isLiteServerUrlUsable(url)) {
s_addError = TR("lite_net_invalid_url"); s_addError = TR("lite_net_invalid_url");
} else { } else {
@@ -188,7 +194,7 @@ void RenderLiteNetworkTab(App* app)
if (!exists) { if (!exists) {
config::Settings::LiteServerPreference p; config::Settings::LiteServerPreference p;
p.url = url; p.url = url;
p.label = s_addLabel[0] ? std::string(s_addLabel) : url; p.label = !addLabel.empty() ? addLabel : url;
p.enabled = true; p.enabled = true;
servers.push_back(p); servers.push_back(p);
st->setLiteServers(servers); st->setLiteServers(servers);

View File

@@ -12,6 +12,7 @@
#include "../../app.h" #include "../../app.h"
#include "../../config/settings.h" #include "../../config/settings.h"
#include "../../util/i18n.h" #include "../../util/i18n.h"
#include "../../util/payment_uri.h"
#include "../../util/platform.h" #include "../../util/platform.h"
#include "../../util/text_format.h" #include "../../util/text_format.h"
#include "../../config/version.h" #include "../../config/version.h"
@@ -365,6 +366,7 @@ static void RenderRecentReceived(const AddressInfo& /* addr */,
if (tx.type != "receive" && tx.type != "mined") continue; if (tx.type != "receive" && tx.type != "mined") continue;
recvs.push_back(&tx); recvs.push_back(&tx);
} }
if (recvs.size() > 4) recvs.resize(4); // show only the 4 most recent (newest-first)
float listH = std::max(rowH, ImGui::GetContentRegionAvail().y); float listH = std::max(rowH, ImGui::GetContentRegionAvail().y);
@@ -539,11 +541,10 @@ void RenderReceiveTab(App* app)
// Generate QR data // Generate QR data
std::string qr_data = selected.address; std::string qr_data = selected.address;
if (s_request_amount > 0) { if (s_request_amount > 0) {
qr_data = std::string("dragonx:") + selected.address + // Canonical, URL-encoded "drgx:" URI via the shared builder. The old inline code emitted an
"?amount=" + std::to_string(s_request_amount); // unparseable "dragonx:" scheme and appended the memo raw (spaces/&/= corrupted the QR).
if (s_request_memo[0] && isZ) { qr_data = util::buildPaymentUri(selected.address, s_request_amount, "",
qr_data += "&memo=" + std::string(s_request_memo); (s_request_memo[0] && isZ) ? std::string(s_request_memo) : std::string());
}
} }
if (qr_data != s_cached_qr_data) { if (qr_data != s_cached_qr_data) {
if (s_qr_texture) { if (s_qr_texture) {

View File

@@ -5,6 +5,7 @@
#include "request_payment_dialog.h" #include "request_payment_dialog.h"
#include "../../app.h" #include "../../app.h"
#include "../../util/i18n.h" #include "../../util/i18n.h"
#include "../../util/payment_uri.h"
#include "../notifications.h" #include "../notifications.h"
#include "../schema/ui_schema.h" #include "../schema/ui_schema.h"
#include "../widgets/qr_code.h" #include "../widgets/qr_code.h"
@@ -34,59 +35,8 @@ static bool s_uri_dirty = true;
// Helper to build payment URI // Helper to build payment URI
static std::string buildPaymentUri() static std::string buildPaymentUri()
{ {
if (s_address[0] == '\0') return ""; // Route through the shared builder so this dialog and the receive tab can't diverge on scheme/encoding.
return dragonx::util::buildPaymentUri(s_address, s_amount, s_label, s_memo);
std::ostringstream uri;
uri << "drgx:" << s_address;
bool hasParams = false;
auto addParam = [&](const char* key, const std::string& value) {
if (value.empty()) return;
uri << (hasParams ? "&" : "?") << key << "=" << value;
hasParams = true;
};
if (s_amount > 0) {
std::ostringstream amt;
amt << std::fixed << std::setprecision(8) << s_amount;
addParam("amount", amt.str());
}
if (s_label[0] != '\0') {
// URL encode label
std::string encoded;
for (char c : std::string(s_label)) {
if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
encoded += c;
} else if (c == ' ') {
encoded += "%20";
} else {
char hex[4];
snprintf(hex, sizeof(hex), "%%%02X", (unsigned char)c);
encoded += hex;
}
}
addParam("label", encoded);
}
if (s_memo[0] != '\0') {
// URL encode memo
std::string encoded;
for (char c : std::string(s_memo)) {
if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
encoded += c;
} else if (c == ' ') {
encoded += "%20";
} else {
char hex[4];
snprintf(hex, sizeof(hex), "%%%02X", (unsigned char)c);
encoded += hex;
}
}
addParam("memo", encoded);
}
return uri.str();
} }
void RequestPaymentDialog::show(const std::string& address) void RequestPaymentDialog::show(const std::string& address)

View File

@@ -794,9 +794,20 @@ void RenderSendConfirmPopup(App* app) {
Type().text(TypeStyle::Body2, TR("sending")); Type().text(TypeStyle::Body2, TR("sending"));
} else { } else {
if (TactileButton(TR("confirm_and_send"), ImVec2(S.button("tabs.send", "confirm-button").width, std::max(schema::UI().drawElement("tabs.send", "confirm-btn-min-height").size, schema::UI().drawElement("tabs.send", "confirm-btn-base-height").size * popVs)), S.resolveFont(S.button("tabs.send", "confirm-button").font))) { if (TactileButton(TR("confirm_and_send"), ImVec2(S.button("tabs.send", "confirm-button").width, std::max(schema::UI().drawElement("tabs.send", "confirm-btn-min-height").size, schema::UI().drawElement("tabs.send", "confirm-btn-base-height").size * popVs)), S.resolveFont(S.button("tabs.send", "confirm-button").font))) {
// Re-validate against LIVE state — the confirm dialog persists across frames, so the
// balance could have dropped or sync (re)started (or the fee bumped total over available)
// since Review. Don't broadcast a now-invalid transaction.
const auto& liveState = app->getWalletState();
double liveAvail = GetAvailableBalance(app);
if (!app->isConnected() || liveState.sync.syncing || s_amount <= 0 ||
(s_amount + s_fee) > liveAvail) {
s_tx_status = "Cannot send now — check connection, sync, and available balance.";
s_status_success = false;
s_status_timestamp = ImGui::GetTime();
}
// A locked encrypted lite wallet can't spend; prompt to unlock instead of sending // A locked encrypted lite wallet can't spend; prompt to unlock instead of sending
// (the backend would otherwise reject with "Wallet is locked"). // (the backend would otherwise reject with "Wallet is locked").
if (app->liteWallet() && app->getWalletState().isLocked()) { else if (app->liteWallet() && app->getWalletState().isLocked()) {
app->requestLiteUnlock(); app->requestLiteUnlock();
} else { } else {
s_sending = true; s_sending = true;
@@ -889,12 +900,19 @@ static void RenderActionButtons(App* app, float width, float vScale,
auto& S = schema::UI(); auto& S = schema::UI();
const auto& state = app->getWalletState(); const auto& state = app->getWalletState();
double total = s_amount + s_fee; double total = s_amount + s_fee;
// Block spending from a view-only source (imported viewing key, no spending key) — it would only
// fail at submit. Defaults to spendable if the address isn't in the loaded list yet.
bool sourceSpendable = true;
for (const auto& a : state.addresses) {
if (a.address == s_from_address) { sourceSpendable = a.has_spending_key; break; }
}
bool can_send = app->isConnected() && bool can_send = app->isConnected() &&
!state.sync.syncing && !state.sync.syncing &&
is_valid_address && is_valid_address &&
s_amount > 0 && s_amount > 0 &&
s_from_address[0] != '\0' && s_from_address[0] != '\0' &&
total <= available && total <= available &&
sourceSpendable &&
!s_sending; !s_sending;
float btnGap = Layout::spacingMd(); float btnGap = Layout::spacingMd();
@@ -934,6 +952,8 @@ static void RenderActionButtons(App* app, float width, float vScale,
material::Tooltip("%s", TR("send_tooltip_enter_amount")); material::Tooltip("%s", TR("send_tooltip_enter_amount"));
else if (total > available) else if (total > available)
material::Tooltip("%s", TR("send_tooltip_exceeds_balance")); material::Tooltip("%s", TR("send_tooltip_exceeds_balance"));
else if (!sourceSpendable)
material::Tooltip("%s", "View-only address — no spending key, cannot send");
else if (s_sending) else if (s_sending)
material::Tooltip("%s", TR("send_tooltip_in_progress")); material::Tooltip("%s", TR("send_tooltip_in_progress"));
} }
@@ -1025,6 +1045,7 @@ static void RenderRecentSends(const WalletState& state, float width, ImFont* cap
if (tx.type != "send" && tx.type != "shield") continue; if (tx.type != "send" && tx.type != "shield") continue;
sends.push_back(&tx); sends.push_back(&tx);
} }
if (sends.size() > 4) sends.resize(4); // show only the 4 most recent (newest-first)
float listH = std::max(rowH, ImGui::GetContentRegionAvail().y); float listH = std::max(rowH, ImGui::GetContentRegionAvail().y);
@@ -1321,7 +1342,8 @@ void RenderSendTab(App* app)
// USD input mode — no step buttons (step=0) // USD input mode — no step buttons (step=0)
ImGui::PushItemWidth(amtInputW); ImGui::PushItemWidth(amtInputW);
if (ImGui::InputDouble("##AmountUSD", &s_usd_amount, 0, 0, "$%.2f")) { if (ImGui::InputDouble("##AmountUSD", &s_usd_amount, 0, 0, "$%.2f")) {
s_amount = s_usd_amount / market.price_usd; // Normalize to 8dp (satoshi precision) so the reviewed/sent DRGX matches the preview.
s_amount = std::round((s_usd_amount / market.price_usd) * 1e8) / 1e8;
} }
// Draw DRGX equivalent inside the input field (right-aligned overlay) // Draw DRGX equivalent inside the input field (right-aligned overlay)
{ {
@@ -1342,6 +1364,9 @@ void RenderSendTab(App* app)
// DRGX input mode — no step buttons (step=0) // DRGX input mode — no step buttons (step=0)
ImGui::PushItemWidth(amtInputW); ImGui::PushItemWidth(amtInputW);
if (ImGui::InputDouble("##Amount", &s_amount, 0, 0, "%.8f")) { if (ImGui::InputDouble("##Amount", &s_amount, 0, 0, "%.8f")) {
// Normalize to 8dp so digits past satoshi precision aren't silently dropped at send.
s_amount = std::round(s_amount * 1e8) / 1e8;
if (s_amount < 0) s_amount = 0;
if (market.price_usd > 0) if (market.price_usd > 0)
s_usd_amount = s_amount * market.price_usd; s_usd_amount = s_amount * market.price_usd;
} }
@@ -1434,9 +1459,13 @@ void RenderSendTab(App* app)
ImVec2(colW, memoInputH)); ImVec2(colW, memoInputH));
ImGui::PopItemWidth(); ImGui::PopItemWidth();
// The Sapling memo limit is in BYTES (multi-byte UTF-8 fills it faster than visible chars);
// warn in colour once the field is at its cap so it's clear input has stopped being accepted.
size_t memo_len = strlen(s_memo); size_t memo_len = strlen(s_memo);
snprintf(buf, sizeof(buf), "%zu / %d", memo_len, (int)S.drawElement("business", "memo-max-length").size); size_t memoMax = (size_t)S.drawElement("business", "memo-max-length").size;
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf); bool memoAtCap = memo_len + 1 >= memoMax;
snprintf(buf, sizeof(buf), "%zu / %zu bytes", memo_len, memoMax);
Type().textColored(TypeStyle::Caption, memoAtCap ? Warning() : OnSurfaceDisabled(), buf);
} }
// Divider before action buttons // Divider before action buttons

View File

@@ -129,6 +129,10 @@ void ShieldDialog::render(App* app)
} }
ImGui::EndCombo(); ImGui::EndCombo();
} }
if (state.z_addresses.empty()) {
material::Type().textColored(material::TypeStyle::Caption, material::Warning(),
"No shielded (z) address yet — create one on the Receive tab first.");
}
ImGui::Spacing(); ImGui::Spacing();
@@ -166,8 +170,11 @@ void ShieldDialog::render(App* app)
ImGui::Spacing(); ImGui::Spacing();
} }
// Buttons // Buttons — guard on connection/sync like the Send tab (a disconnected or mid-sync submit just
bool can_submit = !s_operation_pending && s_to_address[0] != '\0'; // fails at the daemon with a raw error).
bool sh_connected = app->isConnected();
bool sh_syncing = state.sync.syncing;
bool can_submit = !s_operation_pending && s_to_address[0] != '\0' && sh_connected && !sh_syncing;
if (!can_submit) ImGui::BeginDisabled(); if (!can_submit) ImGui::BeginDisabled();
@@ -244,6 +251,11 @@ void ShieldDialog::render(App* app)
} }
if (!can_submit) ImGui::EndDisabled(); if (!can_submit) ImGui::EndDisabled();
if (!can_submit && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
if (!sh_connected) material::Tooltip("%s", TR("send_tooltip_not_connected"));
else if (sh_syncing) material::Tooltip("%s", TR("send_tooltip_syncing"));
else if (s_to_address[0]=='\0') material::Tooltip("%s", TR("shield_select_z"));
}
ImGui::SameLine(); ImGui::SameLine();

View File

@@ -80,6 +80,9 @@ void ValidateAddressDialog::render(App* app)
s_error_message.clear(); s_error_message.clear();
std::string address(s_address_input); std::string address(s_address_input);
// Trim whitespace/newlines a pasted address often carries (the daemon would reject it).
while (!address.empty() && (address.front()==' '||address.front()=='\t'||address.front()=='\n'||address.front()=='\r')) address.erase(address.begin());
while (!address.empty() && (address.back()==' '||address.back()=='\t'||address.back()=='\n'||address.back()=='\r')) address.pop_back();
// Determine if z-address or t-address // Determine if z-address or t-address
bool is_zaddr = !address.empty() && address[0] == 'z'; bool is_zaddr = !address.empty() && address[0] == 'z';

View File

@@ -51,12 +51,14 @@ public:
} }
using namespace material; using namespace material;
const float dp = Layout::dpiScale(); const float dp = Layout::dpiScale();
const auto p = s_updater->getProgress();
// No window X during an active download/verify/extract — closing would orphan/block on the
// worker; the in-dialog Cancel is the intended way to abort.
const bool active = (p.state == St::Downloading || p.state == St::Verifying || p.state == St::Extracting);
OverlayDialogSpec ov; OverlayDialogSpec ov;
ov.title = TR("xmrig_update_title"); ov.p_open = &s_open; ov.title = TR("xmrig_update_title"); ov.p_open = active ? nullptr : &s_open;
ov.style = OverlayStyle::BlurFloat; ov.cardWidth = 480.0f; ov.cardBottomViewportRatio = 0.94f; ov.style = OverlayStyle::BlurFloat; ov.cardWidth = 480.0f; ov.cardBottomViewportRatio = 0.94f;
if (BeginOverlayDialog(ov)) { if (BeginOverlayDialog(ov)) {
const auto p = s_updater->getProgress();
using St = util::XmrigUpdater::State;
switch (p.state) { switch (p.state) {
case St::Checking: renderChecking(dp, p); break; case St::Checking: renderChecking(dp, p); break;
case St::UpToDate: case St::UpToDate:

View File

@@ -724,9 +724,13 @@ bool Bootstrap::verifyChecksums(const std::string& zipPath, const std::string& b
bool haveMD5 = !md5Content.empty(); bool haveMD5 = !md5Content.empty();
if (!haveSHA256 && !haveMD5) { if (!haveSHA256 && !haveMD5) {
DEBUG_LOGF("[Bootstrap] Warning: no checksum files available — skipping verification\n"); // Fail CLOSED: refuse to install an unverified multi-GB archive (mirrors the xmrig/daemon
// Allow the process to continue (server may not have checksum files yet) // updaters, which refuse when no checksum is published). Trusting an unverified download of
return true; // this size is a real integrity/security risk; a missing checksum is a server-side gap to fix.
DEBUG_LOGF("[Bootstrap] No checksum published — refusing to install an unverified archive\n");
setProgress(State::Failed, "Verification failed: no checksum was published for the bootstrap. "
"Refusing to install an unverified archive.");
return false;
} }
// Determine progress ranges: if both checksums exist, split 0-50% / 50-100% // Determine progress ranges: if both checksums exist, split 0-50% / 50-100%

View File

@@ -5,12 +5,58 @@
#include "payment_uri.h" #include "payment_uri.h"
#include <sstream> #include <sstream>
#include <iomanip>
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <cstdio>
namespace dragonx { namespace dragonx {
namespace util { namespace util {
std::string urlEncode(const std::string& value)
{
std::string out;
out.reserve(value.size());
for (unsigned char c : value) {
if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
out += static_cast<char>(c);
} else if (c == ' ') {
out += "%20";
} else {
char hex[4];
std::snprintf(hex, sizeof(hex), "%%%02X", c);
out += hex;
}
}
return out;
}
std::string buildPaymentUri(const std::string& address, double amount,
const std::string& label, const std::string& memo)
{
if (address.empty()) return "";
std::ostringstream uri;
uri << "drgx:" << address;
bool hasParams = false;
auto addParam = [&](const char* key, const std::string& value) {
if (value.empty()) return;
uri << (hasParams ? "&" : "?") << key << "=" << value;
hasParams = true;
};
if (amount > 0.0) {
std::ostringstream amt;
amt << std::fixed << std::setprecision(8) << amount;
addParam("amount", amt.str());
}
addParam("label", urlEncode(label));
addParam("memo", urlEncode(memo));
return uri.str();
}
std::string urlDecode(const std::string& encoded) std::string urlDecode(const std::string& encoded)
{ {
std::string result; std::string result;

View File

@@ -40,6 +40,21 @@ PaymentURI parsePaymentURI(const std::string& uri);
*/ */
std::string urlDecode(const std::string& encoded); std::string urlDecode(const std::string& encoded);
/**
* @brief URL-encode a string (RFC 3986 unreserved kept; ' ' -> %20; others -> %HH).
*/
std::string urlEncode(const std::string& value);
/**
* @brief Build a canonical DragonX payment URI: drgx:<address>[?amount=..&label=..&memo=..],
* with query parameters URL-encoded. amount<=0 and empty label/memo are omitted.
*
* Single source of truth so the receive tab and the request-payment dialog can't diverge on the
* scheme or encoding (the receive tab previously emitted an unparseable "dragonx:" URI with a raw memo).
*/
std::string buildPaymentUri(const std::string& address, double amount = 0.0,
const std::string& label = "", const std::string& memo = "");
/** /**
* @brief Check if a string is a valid payment URI * @brief Check if a string is a valid payment URI
* @param str String to check * @param str String to check