Files
ObsidianDragon/src/util/payment_uri.cpp
DanS 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

196 lines
5.7 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "payment_uri.h"
#include <sstream>
#include <iomanip>
#include <algorithm>
#include <cctype>
#include <cstdio>
namespace dragonx {
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 result;
result.reserve(encoded.size());
for (size_t i = 0; i < encoded.size(); i++) {
if (encoded[i] == '%' && i + 2 < encoded.size()) {
// Decode hex pair
char hex[3] = { encoded[i + 1], encoded[i + 2], '\0' };
char* end;
long val = strtol(hex, &end, 16);
if (end == hex + 2) {
result += static_cast<char>(val);
i += 2;
continue;
}
} else if (encoded[i] == '+') {
result += ' ';
continue;
}
result += encoded[i];
}
return result;
}
bool isPaymentURI(const std::string& str)
{
// Check for drgx: or hush: prefix (case insensitive)
std::string lower = str;
std::transform(lower.begin(), lower.end(), lower.begin(), ::tolower);
return lower.substr(0, 5) == "drgx:" || lower.substr(0, 5) == "hush:";
}
PaymentURI parsePaymentURI(const std::string& uri)
{
PaymentURI result;
result.valid = false;
// Check prefix
if (!isPaymentURI(uri)) {
result.error = "Invalid URI scheme. Expected drgx: or hush:";
return result;
}
// Skip the scheme prefix (drgx: or hush:)
size_t schemeEnd = uri.find(':');
if (schemeEnd == std::string::npos) {
result.error = "Invalid URI format";
return result;
}
std::string remainder = uri.substr(schemeEnd + 1);
// Handle double-slash format (drgx://address)
if (remainder.size() >= 2 && remainder[0] == '/' && remainder[1] == '/') {
remainder = remainder.substr(2);
}
// Split address and query string
size_t queryStart = remainder.find('?');
if (queryStart == std::string::npos) {
// No query parameters, just the address
result.address = remainder;
} else {
result.address = remainder.substr(0, queryStart);
std::string queryString = remainder.substr(queryStart + 1);
// Parse query parameters
std::istringstream queryStream(queryString);
std::string param;
while (std::getline(queryStream, param, '&')) {
size_t eqPos = param.find('=');
if (eqPos == std::string::npos) continue;
std::string key = param.substr(0, eqPos);
std::string value = urlDecode(param.substr(eqPos + 1));
// Convert key to lowercase for case-insensitive matching
std::transform(key.begin(), key.end(), key.begin(), ::tolower);
if (key == "amount" || key == "amt") {
try {
result.amount = std::stod(value);
} catch (...) {
// Invalid amount, keep as 0
}
} else if (key == "label") {
result.label = value;
} else if (key == "memo") {
result.memo = value;
} else if (key == "message" || key == "msg") {
result.message = value;
}
}
}
// Validate address
if (result.address.empty()) {
result.error = "No address specified in URI";
return result;
}
// Basic address format check. NOTE: this is format-only by design — the send flow
// checksum-validates the recipient (isValidBase58Check / shielded check) before broadcasting,
// so an invalid-checksum address parsed here can never actually be sent to.
bool validFormat = false;
// z-address: starts with 'zs' and is 78+ chars
if (result.address[0] == 'z' && result.address.size() >= 78) {
validFormat = true;
}
// t-address: starts with 'R' (DragonX) or 't' (HUSH) and is ~34 chars
else if ((result.address[0] == 'R' || result.address[0] == 't') &&
result.address.size() >= 26 && result.address.size() <= 36) {
validFormat = true;
}
if (!validFormat) {
result.error = "Invalid address format";
return result;
}
// Validate amount if present
if (result.amount < 0) {
result.error = "Invalid negative amount";
return result;
}
result.valid = true;
return result;
}
} // namespace util
} // namespace dragonx