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:
2026-06-07 14:43:34 -05:00
parent 3cec333d84
commit 070a516f4e
5 changed files with 204 additions and 7 deletions

View File

@@ -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)