fix(send): validate recipient address checksums (Base58Check + Bech32)
The send screen labelled any prefix+length match as a "Valid" address, so a mistyped address that still matched the pattern passed the gate. Add pure, offline checksum validation — Base58Check (transparent R-addresses) and Bech32 (Sapling zs-addresses) — and require it in the validity check. Both verifiers are version-byte/HRP agnostic (the HRP is taken from the string, the Base58 checksum is chain-independent), so a correct implementation never rejects a genuine address while catching transcription errors. Works for both build variants (no daemon round-trip), unit-tested against standard BIP173 / Base58Check vectors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<std::string> 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)
|
||||
|
||||
131
src/util/address_validation.cpp
Normal file
131
src/util/address_validation.cpp
Normal file
@@ -0,0 +1,131 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "address_validation.h"
|
||||
|
||||
#include <sodium.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
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<std::uint8_t>& out)
|
||||
{
|
||||
std::vector<std::uint8_t> 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<int>(p - kBase58);
|
||||
for (auto& b : bytes) {
|
||||
carry += static_cast<int>(b) * 58;
|
||||
b = static_cast<std::uint8_t>(carry & 0xff);
|
||||
carry >>= 8;
|
||||
}
|
||||
while (carry > 0) {
|
||||
bytes.push_back(static_cast<std::uint8_t>(carry & 0xff));
|
||||
carry >>= 8;
|
||||
}
|
||||
}
|
||||
std::vector<std::uint8_t> 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<int>& 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<std::uint32_t>(v);
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
if ((top >> i) & 1) chk ^= kGen[i];
|
||||
}
|
||||
}
|
||||
return chk;
|
||||
}
|
||||
|
||||
std::vector<int> bech32HrpExpand(const std::string& hrp)
|
||||
{
|
||||
std::vector<int> out;
|
||||
out.reserve(hrp.size() * 2 + 1);
|
||||
for (char c : hrp) out.push_back(static_cast<unsigned char>(c) >> 5);
|
||||
out.push_back(0);
|
||||
for (char c : hrp) out.push_back(static_cast<unsigned char>(c) & 31);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool isValidBase58Check(const std::string& s)
|
||||
{
|
||||
if (s.size() < 5 || s.size() > 256) return false;
|
||||
std::vector<std::uint8_t> 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<char>(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<int> 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<int>(p - kBech32));
|
||||
}
|
||||
|
||||
std::vector<int> 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
|
||||
28
src/util/address_validation.h
Normal file
28
src/util/address_validation.h
Normal file
@@ -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 <string>
|
||||
|
||||
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
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user