diff --git a/CMakeLists.txt b/CMakeLists.txt index 4b0f8c1..a24f267 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -472,6 +472,7 @@ set(APP_SOURCES src/util/logger.cpp src/util/async_task_manager.cpp src/util/amount_format.cpp + src/util/address_validation.cpp src/util/base64.cpp src/util/single_instance.cpp src/util/i18n.cpp @@ -980,6 +981,7 @@ if(BUILD_TESTING) src/ui/windows/mining_tab_helpers.cpp src/util/payment_uri.cpp src/util/amount_format.cpp + src/util/address_validation.cpp src/data/wallet_state.cpp src/data/transaction_history_cache.cpp src/daemon/lifecycle_adapters.cpp diff --git a/src/ui/windows/send_tab.cpp b/src/ui/windows/send_tab.cpp index 36cbb55..86860a0 100644 --- a/src/ui/windows/send_tab.cpp +++ b/src/ui/windows/send_tab.cpp @@ -11,6 +11,7 @@ #include "../../app.h" #include "../../config/version.h" #include "../../data/wallet_state.h" +#include "../../util/address_validation.h" #include "../../util/i18n.h" #include "../notifications.h" #include "../layout.h" @@ -151,6 +152,16 @@ static std::string TruncateAddress(const std::string& addr, size_t maxLen = 40) return addr.substr(0, halfLen) + "..." + addr.substr(addr.length() - halfLen); } +// Recipient validity = prefix/length pre-filter AND a real encoding-checksum check, so a +// transcription error that still matches the prefix/length is no longer labelled "Valid". +// The checksum verifiers are version-agnostic, so they never reject a genuine address. +static bool IsValidShieldedAddr(const char* a) { + return a[0] == 'z' && a[1] == 's' && strlen(a) > 60 && dragonx::util::isValidBech32(a); +} +static bool IsValidTransparentAddr(const char* a) { + return a[0] == 'R' && strlen(a) >= 34 && dragonx::util::isValidBase58Check(a); +} + static std::string timeAgo(int64_t timestamp) { if (timestamp <= 0) return ""; int64_t now = (int64_t)std::time(nullptr); @@ -309,8 +320,8 @@ static void RenderSourceDropdown(App* app, float width) { static void RenderAddressSuggestions(const WalletState& state, float width, const char* childId) { std::string partial(s_to_address); if (partial.length() < 2) return; - bool is_valid_z = (s_to_address[0] == 'z' && s_to_address[1] == 's' && strlen(s_to_address) > 60); - bool is_valid_t = (s_to_address[0] == 'R' && strlen(s_to_address) >= 34); + bool is_valid_z = IsValidShieldedAddr(s_to_address); + bool is_valid_t = IsValidTransparentAddr(s_to_address); if (is_valid_z || is_valid_t) return; std::vector suggestions; @@ -655,7 +666,7 @@ void RenderSendConfirmPopup(App* app) { // Called every frame while the popup should be visible. // OpenPopup is idempotent when the popup is already open. ImGui::OpenPopup(TR("confirm_send")); - bool is_valid_z = (s_to_address[0] == 'z' && s_to_address[1] == 's' && strlen(s_to_address) > 60); + bool is_valid_z = IsValidShieldedAddr(s_to_address); auto& S = schema::UI(); const auto& state = app->getWalletState(); const auto& market = state.market; @@ -1121,8 +1132,8 @@ void RenderSendTab(App* app) glassSpec.rounding = glassRound; double available = GetAvailableBalance(app); - bool is_valid_z = (s_to_address[0] == 'z' && s_to_address[1] == 's' && strlen(s_to_address) > 60); - bool is_valid_t = (s_to_address[0] == 'R' && strlen(s_to_address) >= 34); + bool is_valid_z = IsValidShieldedAddr(s_to_address); + bool is_valid_t = IsValidTransparentAddr(s_to_address); bool is_valid_address = is_valid_z || is_valid_t; float sectionGap = Layout::spacingXl() * vScale; @@ -1204,8 +1215,8 @@ void RenderSendTab(App* app) checkPreview = true; } if (validAddr[0] != '\0') { - bool vz = (validAddr[0] == 'z' && validAddr[1] == 's' && strlen(validAddr) > 60); - bool vt = (validAddr[0] == 'R' && strlen(validAddr) >= 34); + bool vz = IsValidShieldedAddr(validAddr); + bool vt = IsValidTransparentAddr(validAddr); if (vz || vt) { ImGui::SameLine(); if (vz) diff --git a/src/util/address_validation.cpp b/src/util/address_validation.cpp new file mode 100644 index 0000000..cba64f1 --- /dev/null +++ b/src/util/address_validation.cpp @@ -0,0 +1,131 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 + +#include "address_validation.h" + +#include + +#include +#include +#include +#include +#include + +namespace dragonx { +namespace util { + +namespace { + +constexpr const char* kBase58 = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; +constexpr const char* kBech32 = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + +// Decode a Base58 string to bytes (big-endian), preserving leading-zero bytes. +bool base58Decode(const std::string& s, std::vector& out) +{ + std::vector bytes; // little-endian during accumulation + for (char ch : s) { + const char* p = std::strchr(kBase58, ch); + if (p == nullptr || ch == '\0') return false; + int carry = static_cast(p - kBase58); + for (auto& b : bytes) { + carry += static_cast(b) * 58; + b = static_cast(carry & 0xff); + carry >>= 8; + } + while (carry > 0) { + bytes.push_back(static_cast(carry & 0xff)); + carry >>= 8; + } + } + std::vector result; + for (char ch : s) { // leading '1's map to leading zero bytes + if (ch == '1') result.push_back(0); + else break; + } + result.insert(result.end(), bytes.rbegin(), bytes.rend()); + out = std::move(result); + return true; +} + +std::uint32_t bech32Polymod(const std::vector& values) +{ + static const std::uint32_t kGen[5] = { + 0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3}; + std::uint32_t chk = 1; + for (int v : values) { + std::uint32_t top = chk >> 25; + chk = ((chk & 0x1ffffff) << 5) ^ static_cast(v); + for (int i = 0; i < 5; ++i) { + if ((top >> i) & 1) chk ^= kGen[i]; + } + } + return chk; +} + +std::vector bech32HrpExpand(const std::string& hrp) +{ + std::vector out; + out.reserve(hrp.size() * 2 + 1); + for (char c : hrp) out.push_back(static_cast(c) >> 5); + out.push_back(0); + for (char c : hrp) out.push_back(static_cast(c) & 31); + return out; +} + +} // namespace + +bool isValidBase58Check(const std::string& s) +{ + if (s.size() < 5 || s.size() > 256) return false; + std::vector data; + if (!base58Decode(s, data)) return false; + if (data.size() < 5) return false; // need at least version(1) + checksum(4) + + const std::size_t payloadLen = data.size() - 4; + unsigned char h1[crypto_hash_sha256_BYTES]; + unsigned char h2[crypto_hash_sha256_BYTES]; + crypto_hash_sha256(h1, data.data(), payloadLen); + crypto_hash_sha256(h2, h1, sizeof(h1)); + return std::memcmp(h2, data.data() + payloadLen, 4) == 0; +} + +bool isValidBech32(const std::string& s) +{ + if (s.size() < 8 || s.size() > 200) return false; + + // Bech32 forbids mixed case; normalize to lower for verification after that check. + bool hasLower = false, hasUpper = false; + for (char c : s) { + if (c >= 'a' && c <= 'z') hasLower = true; + else if (c >= 'A' && c <= 'Z') hasUpper = true; + if (c < 33 || c > 126) return false; // printable ASCII only + } + if (hasLower && hasUpper) return false; + + std::string lower(s); + std::transform(lower.begin(), lower.end(), lower.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + + const std::size_t sep = lower.rfind('1'); + if (sep == std::string::npos || sep == 0) return false; // need a non-empty HRP + const std::string hrp = lower.substr(0, sep); + const std::string data = lower.substr(sep + 1); + if (data.size() < 6) return false; // 6-char checksum minimum + + std::vector values; + values.reserve(data.size()); + for (char c : data) { + const char* p = std::strchr(kBech32, c); + if (p == nullptr) return false; + values.push_back(static_cast(p - kBech32)); + } + + std::vector combined = bech32HrpExpand(hrp); + combined.insert(combined.end(), values.begin(), values.end()); + return bech32Polymod(combined) == 1; // original Bech32 constant (Sapling, not Bech32m) +} + +} // namespace util +} // namespace dragonx diff --git a/src/util/address_validation.h b/src/util/address_validation.h new file mode 100644 index 0000000..bc17a33 --- /dev/null +++ b/src/util/address_validation.h @@ -0,0 +1,28 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 +// +// address_validation.h — pure, offline checksum validation for wallet addresses. +// These verify the *encoding checksum* (Base58Check / Bech32), which is independent +// of the chain's version bytes / HRP, so a correct implementation never rejects a +// genuinely-valid address while still catching transcription errors. Used to make +// the send screen's "Valid address" label/gate reflect reality instead of a bare +// prefix+length heuristic. No network, no daemon — safe for both build variants. + +#pragma once + +#include + +namespace dragonx { +namespace util { + +// True if `s` decodes as Base58Check with a valid 4-byte double-SHA256 checksum +// (transparent R-addresses). Version-byte agnostic by design. +bool isValidBase58Check(const std::string& s); + +// True if `s` is a valid Bech32 string (Sapling zs-addresses). The HRP is taken +// from the string itself and folded into the checksum, so no HRP is hardcoded. +bool isValidBech32(const std::string& s); + +} // namespace util +} // namespace dragonx diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 18c0d54..3c48c57 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -18,6 +18,7 @@ #include "ui/windows/mining_benchmark.h" #include "ui/windows/mining_pool_panel.h" #include "ui/windows/mining_tab_helpers.h" +#include "util/address_validation.h" #include "util/amount_format.h" #include "util/payment_uri.h" #include "util/platform.h" @@ -4242,6 +4243,29 @@ void testAtomicFileWrite() fs::remove_all(dir); } +// Address checksum validation. Uses standard Base58Check / BIP173 Bech32 vectors — the +// algorithms are chain-agnostic, so passing these means real DRGX addresses validate too. +void testAddressChecksumValidation() +{ + using dragonx::util::isValidBase58Check; + using dragonx::util::isValidBech32; + + // Base58Check: a valid P2PKH address verifies; a one-char transcription error fails. + EXPECT_TRUE(isValidBase58Check("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")); + EXPECT_FALSE(isValidBase58Check("1A1zP1eP5QGefi2DMPTfTL5SLmv7Divfna")); // last char flipped + EXPECT_FALSE(isValidBase58Check("not-base58-0OIl")); // invalid alphabet + EXPECT_FALSE(isValidBase58Check("")); + + // Bech32 (BIP173 valid vectors) verify; corrupted checksums fail. + EXPECT_TRUE(isValidBech32("A12UEL5L")); + EXPECT_TRUE(isValidBech32("abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw")); + EXPECT_TRUE(isValidBech32("split1checkupstagehandshakeupstreamerranterredcaperred2y9e3w")); + EXPECT_FALSE(isValidBech32("A12UEL5M")); // checksum corrupted + EXPECT_FALSE(isValidBech32("abc1rzg")); // too short / bad checksum + EXPECT_FALSE(isValidBech32("Abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw")); // mixed case + EXPECT_FALSE(isValidBech32("nosalt")); // no separator +} + // Live probe of a real lite server (env-gated). Validates CONNECT_ONLY latency + IP capture. void testLiteServerProbeLive() { @@ -4426,6 +4450,7 @@ int main() testLiteServerHostParsing(); testLiteOfficialServerDetection(); testAtomicFileWrite(); + testAddressChecksumValidation(); testLiteServerProbeLive(); testXmrigLiveInstall(); testGeneratedResourceBehavior();