From a legacy-wallet migration support case (mined T-funds, 3y-old Silent Dragon wallet.dat): the balance
shows but new-address and migrate both fail with unhelpful/silent results. Two fixes:
1) Real address-creation error. createNewAddress() swallowed the daemon's z_getnewaddress error and the UI
showed a generic "Failed to create shielded address". It now keeps the daemon's reason (friendly-mapped
via friendlyRpcError) in last_address_error_, which the Receive tab and request-payment dialog show
instead of the generic string — so a legacy/degraded wallet sees the actual "HD seed not found". And when
that specific error appears it flips wallet_degraded_ + warns once, even for an EXTERNAL daemon
(detectWalletDegraded() only scans the embedded daemon's log and would otherwise miss it).
2) Migrate-to-seed sweep skipped coinbase. beginSweepToSeedWallet()'s z_mergetoaddress ignores coinbase
UTXOs, so a miner's coinbase-only balance produced "Could not find any funds to merge", the legacy
balance never reached ~0, and the adopt gate stalled silently. The sweep now falls back to
z_shieldcoinbase("*", dest, fee, 50) to the SAME shielded destination when the merge reports no funds.
z_shieldcoinbase returns an opid just like the merge, so the single-opid adopt gate (opid -> txid ->
confirm -> balance ~0 -> adopt) is unchanged, and a batch limit / mixed wallet drains via the existing
"Sweep remaining" re-sweep.
Fund-path: the z_mergetoaddress call and its args are byte-identical; the fallback only ADDS a
z_shieldcoinbase to the same dest when merge finds nothing, and nothing is adopted until the swept funds
confirm and the legacy balance is ~0 (gate unchanged). Runtime-verified behavior (clean build + ctest; the
RPC paths are not pure-testable).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
307 lines
12 KiB
C++
307 lines
12 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
|
|
#include "request_payment_dialog.h"
|
|
#include "../../app.h"
|
|
#include "../../util/i18n.h"
|
|
#include "../../util/payment_uri.h"
|
|
#include "../../util/address_validation.h"
|
|
#include "../notifications.h"
|
|
#include "../schema/ui_schema.h"
|
|
#include "../widgets/qr_code.h"
|
|
#include "../widgets/copy_field.h"
|
|
#include "../material/draw_helpers.h"
|
|
#include "imgui.h"
|
|
|
|
#include <string>
|
|
#include <sstream>
|
|
#include <iomanip>
|
|
|
|
namespace dragonx {
|
|
namespace ui {
|
|
|
|
// Static state
|
|
static bool s_open = false;
|
|
static char s_address[512] = "";
|
|
static double s_amount = 0.0;
|
|
static char s_memo[512] = "";
|
|
static char s_label[128] = "";
|
|
static std::string s_payment_uri;
|
|
static uintptr_t s_qr_texture = 0;
|
|
static int s_qr_width = 0;
|
|
static int s_qr_height = 0;
|
|
static bool s_uri_dirty = true;
|
|
static bool s_creating_address = false; // true while a Create-shielded-address RPC is in flight
|
|
|
|
// Helper to build payment URI
|
|
static std::string buildPaymentUri()
|
|
{
|
|
// 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);
|
|
}
|
|
|
|
void RequestPaymentDialog::show(const std::string& address)
|
|
{
|
|
s_open = true;
|
|
s_amount = 0.0;
|
|
s_memo[0] = '\0';
|
|
s_label[0] = '\0';
|
|
s_payment_uri.clear();
|
|
s_uri_dirty = true;
|
|
s_creating_address = false;
|
|
|
|
// Only a shielded address can be paid (DragonX is a private chain): never prefill a mining address.
|
|
if (!address.empty() && !util::isTransparentAddress(address)) {
|
|
strncpy(s_address, address.c_str(), sizeof(s_address) - 1);
|
|
s_address[sizeof(s_address) - 1] = '\0';
|
|
} else {
|
|
s_address[0] = '\0';
|
|
}
|
|
|
|
// Free old QR texture
|
|
if (s_qr_texture != 0) {
|
|
FreeQRTexture(s_qr_texture);
|
|
s_qr_texture = 0;
|
|
}
|
|
}
|
|
|
|
void RequestPaymentDialog::hide()
|
|
{
|
|
s_open = false;
|
|
s_creating_address = false;
|
|
s_payment_uri.clear();
|
|
if (s_qr_texture != 0) {
|
|
FreeQRTexture(s_qr_texture);
|
|
s_qr_texture = 0;
|
|
}
|
|
}
|
|
|
|
void RequestPaymentDialog::render(App* app)
|
|
{
|
|
if (!s_open) return;
|
|
|
|
auto& S = schema::UI();
|
|
auto win = S.window("dialogs.request-payment");
|
|
auto zAddrLbl = S.label("dialogs.request-payment", "z-addr-label");
|
|
auto zAddrFrontLbl = S.label("dialogs.request-payment", "z-addr-front-label");
|
|
auto zAddrBackLbl = S.label("dialogs.request-payment", "z-addr-back-label");
|
|
auto amountInput = S.input("dialogs.request-payment", "amount-input");
|
|
auto memoInput = S.input("dialogs.request-payment", "memo-input");
|
|
auto qr = S.drawElement("dialogs.request-payment", "qr-code");
|
|
auto actionBtn = S.button("dialogs.request-payment", "action-button");
|
|
|
|
material::OverlayDialogSpec ov;
|
|
ov.title = TR("request_title"); ov.p_open = &s_open;
|
|
ov.style = material::OverlayStyle::BlurFloat;
|
|
ov.cardWidth = win.width; ov.idSuffix = "requestpay";
|
|
if (material::BeginOverlayDialog(ov)) {
|
|
const auto& state = app->getWalletState();
|
|
|
|
ImGui::TextWrapped("%s", TR("request_description"));
|
|
|
|
ImGui::Spacing();
|
|
|
|
// Address selection
|
|
const bool noShielded = state.z_addresses.empty();
|
|
if (noShielded) {
|
|
// Nothing to request to yet — offer to create a shielded address instead of dead-ending.
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()));
|
|
ImGui::TextWrapped("%s", TR("request_no_shielded"));
|
|
ImGui::PopStyleColor();
|
|
ImGui::Spacing();
|
|
if (s_creating_address) {
|
|
ImGui::BeginDisabled();
|
|
material::TactileButton(TR("generating"), ImVec2(0, 0), S.resolveFont(actionBtn.font));
|
|
ImGui::EndDisabled();
|
|
} else {
|
|
ImGui::BeginDisabled(!app->isConnected());
|
|
if (material::TactileButton(TR("request_create_shielded"), ImVec2(0, 0), S.resolveFont(actionBtn.font))) {
|
|
if (app->isConnected()) {
|
|
s_creating_address = true;
|
|
app->createNewZAddress([app](const std::string& addr) {
|
|
s_creating_address = false;
|
|
if (addr.empty()) {
|
|
Notifications::instance().error(app->lastAddressError().empty()
|
|
? std::string(TR("failed_create_shielded")) : app->lastAddressError());
|
|
} else {
|
|
Notifications::instance().success(TR("new_shielded_created"));
|
|
// Pre-select the new address if the dialog is still open and none chosen.
|
|
if (s_open && s_address[0] == '\0') {
|
|
strncpy(s_address, addr.c_str(), sizeof(s_address) - 1);
|
|
s_address[sizeof(s_address) - 1] = '\0';
|
|
s_uri_dirty = true;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
ImGui::EndDisabled();
|
|
}
|
|
} else {
|
|
ImGui::Text("%s", TR("request_receive_address"));
|
|
|
|
std::string addr_display = s_address[0] ? s_address : std::string(TR("request_select_address"));
|
|
if (addr_display.length() > static_cast<size_t>(zAddrLbl.truncate)) {
|
|
addr_display = addr_display.substr(0, zAddrFrontLbl.truncate) + "..." + addr_display.substr(addr_display.length() - zAddrBackLbl.truncate);
|
|
}
|
|
if (s_address[0]) {
|
|
std::string l = app->getAddressLabel(s_address);
|
|
if (!l.empty()) addr_display = l + " " + addr_display;
|
|
}
|
|
|
|
ImGui::SetNextItemWidth(-1);
|
|
if (ImGui::BeginCombo("##Address", addr_display.c_str())) {
|
|
ImGui::TextDisabled("%s", TR("request_shielded_addrs"));
|
|
for (size_t i = 0; i < state.z_addresses.size(); i++) {
|
|
const auto& addr = state.z_addresses[i];
|
|
std::string label = addr.address;
|
|
if (label.length() > static_cast<size_t>(zAddrLbl.truncate)) {
|
|
label = label.substr(0, zAddrFrontLbl.truncate) + "..." + label.substr(label.length() - zAddrBackLbl.truncate);
|
|
}
|
|
std::string clbl = app->getAddressLabel(addr.address);
|
|
std::string shown = (clbl.empty() ? label : (clbl + " " + label)) + "##" + addr.address;
|
|
|
|
if (ImGui::Selectable(shown.c_str(), addr.address == s_address)) {
|
|
strncpy(s_address, addr.address.c_str(), sizeof(s_address) - 1);
|
|
s_uri_dirty = true;
|
|
}
|
|
}
|
|
// No mining (transparent) addresses: they only receive mining rewards, so a request to one
|
|
// could never be paid.
|
|
ImGui::EndCombo();
|
|
}
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
|
|
// Amount (optional)
|
|
ImGui::Text("%s", TR("request_amount"));
|
|
ImGui::SetNextItemWidth(amountInput.width * Layout::dpiScale());
|
|
if (ImGui::InputDouble("##Amount", &s_amount, 0.1, 1.0, "%.8f")) {
|
|
s_uri_dirty = true;
|
|
}
|
|
ImGui::SameLine();
|
|
ImGui::TextDisabled("DRGX");
|
|
|
|
ImGui::Spacing();
|
|
|
|
// Label (optional)
|
|
if (material::LabeledInput(TR("request_label"), "##Label", s_label, sizeof(s_label))) {
|
|
s_uri_dirty = true;
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
|
|
// Memo (optional, only for z-addr)
|
|
bool is_zaddr = (s_address[0] == 'z');
|
|
if (is_zaddr) {
|
|
if (material::LabeledInputMultiline(TR("request_memo"), "##Memo", s_memo, sizeof(s_memo),
|
|
ImVec2(-1, memoInput.height > 0 ? memoInput.height : 60))) {
|
|
s_uri_dirty = true;
|
|
}
|
|
} else {
|
|
s_memo[0] = '\0'; // Clear memo for t-addr
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
ImGui::Separator();
|
|
ImGui::Spacing();
|
|
|
|
// Build and display payment URI
|
|
if (s_uri_dirty && s_address[0] != '\0') {
|
|
s_payment_uri = buildPaymentUri();
|
|
|
|
// Generate new QR code
|
|
if (s_qr_texture != 0) {
|
|
FreeQRTexture(s_qr_texture);
|
|
}
|
|
s_qr_texture = GenerateQRTexture(s_payment_uri.c_str(), &s_qr_width, &s_qr_height);
|
|
s_uri_dirty = false;
|
|
}
|
|
|
|
// QR Code display
|
|
if (s_qr_texture != 0) {
|
|
RenderQRCode(s_qr_texture, qr.size > 0 ? qr.size : 200);
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
|
|
// Payment URI display
|
|
if (!s_payment_uri.empty() && s_address[0] != '\0') {
|
|
ImGui::Text("%s", TR("request_payment_uri"));
|
|
// Bordered read-only field: bounds the raw drgx: URI within a box (it horizontal-scrolls if
|
|
// long) rather than 4-char-chunking it like an address or letting a plain field overflow.
|
|
// The "Copy URI" button below copies the full value.
|
|
{
|
|
char uriBuf[2048];
|
|
snprintf(uriBuf, sizeof(uriBuf), "%s", s_payment_uri.c_str());
|
|
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x);
|
|
ImGui::InputText("##URI", uriBuf, sizeof(uriBuf), ImGuiInputTextFlags_ReadOnly);
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
|
|
// Footer buttons: size each to ITS OWN label (action-button.width is a floor, not the size)
|
|
// so "Copy Full Address" never clips its final letter — mirrors qr_popup_dialog's per-label
|
|
// sizing. Font metrics are already DPI-scaled, so don't multiply by dpiScale() here.
|
|
ImFont* btnFont = S.resolveFont(actionBtn.font);
|
|
ImGui::PushFont(btnFont);
|
|
const float btnPad = ImGui::GetStyle().FramePadding.x * 2.0f + 12.0f; // small margin
|
|
float w_uri = ImGui::CalcTextSize(TR("request_copy_uri")).x + btnPad;
|
|
float w_addr = ImGui::CalcTextSize(TR("copy_address")).x + btnPad;
|
|
ImGui::PopFont();
|
|
if (w_uri < actionBtn.width) w_uri = actionBtn.width;
|
|
if (w_addr < actionBtn.width) w_addr = actionBtn.width;
|
|
|
|
// Center the two-button copy row via the shared helper (no extra divider; the Separator
|
|
// above already frames this region). Total = both button widths + the ItemSpacing between.
|
|
material::BeginOverlayDialogFooter(w_uri + w_addr + ImGui::GetStyle().ItemSpacing.x, false);
|
|
|
|
// Copy URI button
|
|
if (material::TactileButton(TR("request_copy_uri"), ImVec2(w_uri, 0), btnFont)) {
|
|
ImGui::SetClipboardText(s_payment_uri.c_str());
|
|
Notifications::instance().success(TR("request_uri_copied"));
|
|
}
|
|
|
|
ImGui::SameLine();
|
|
|
|
if (material::TactileButton(TR("copy_address"), ImVec2(w_addr, 0), btnFont)) {
|
|
ImGui::SetClipboardText(s_address);
|
|
Notifications::instance().success(TR("address_copied"));
|
|
}
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
|
|
// Close button — sized to its own label with the same floor.
|
|
{
|
|
ImFont* btnFont = S.resolveFont(actionBtn.font);
|
|
ImGui::PushFont(btnFont);
|
|
const float btnPad = ImGui::GetStyle().FramePadding.x * 2.0f + 12.0f;
|
|
float w_close = ImGui::CalcTextSize(TR("close")).x + btnPad;
|
|
ImGui::PopFont();
|
|
if (w_close < actionBtn.width) w_close = actionBtn.width;
|
|
// Center the lone Close button via the shared helper (no extra divider — matches the
|
|
// existing tight spacing above it).
|
|
material::BeginOverlayDialogFooter(w_close, false);
|
|
if (material::TactileButton(TR("close"), ImVec2(w_close, 0), btnFont)) {
|
|
s_open = false;
|
|
}
|
|
}
|
|
material::EndOverlayDialog();
|
|
}
|
|
|
|
// Cleanup on close
|
|
if (!s_open) {
|
|
s_payment_uri.clear();
|
|
}
|
|
if (!s_open && s_qr_texture != 0) {
|
|
FreeQRTexture(s_qr_texture);
|
|
s_qr_texture = 0;
|
|
}
|
|
}
|
|
|
|
} // namespace ui
|
|
} // namespace dragonx
|