Skip to content

Commit a562eda

Browse files
feat(qt): DashPay contact payments, seed recovery and identity details
Send to username: the recipient field of the send dialog accepts a DPNS label (the Base58 entry validator would silently drop 0, I, O and l), resolves it through a proved read, and replaces it with the next unused DIP-15 payment address derived from the contact's stored xpub. A lookup discloses what was typed to an evonode, so while typing only a label that cannot be the start of a Dash address (one with a character outside Base58, or a hyphen) is looked up; a label that could still become an address is looked up once the entry is left. The status shown is the DPNS label the proof verified, as registered rather than in its homograph-safe normalized form, together with the derived address; a profile display name is never presented as the destination. The address is reserved when it resolves, labelled for transaction history, and its cursor advances only once the payment was committed; a commit the mempool refuses (TransactionCommitFailed) releases the reservation. Paying a contact starts on the Send tab: the send entry's @ button opens a contact picker (first contact selected, Enter pays), and the DashPay tab has no pay controls of its own; a connected contact's row and tooltip point there. Leaving the recipient field looks a username up through a FocusOut event filter as well as editingFinished, which a field losing focus with an intermediate text does not report. While network activity is off the @ button is disabled and a lookup says DashPay is paused rather than that Platform can't be reached. Seed-only recovery rebuilds the local Platform state of a wallet restored from its recovery phrase: it probes identity indexes by MASTER key hash with a gap of five, where only a proven absence counts (a failed probe ends the session incomplete and the next start retries), restores index 0 as REGISTERED with the lexicographically first of its names or as an identity awaiting a username, and records the identity's signing, encryption and decryption key ids after checking each against the key the wallet derives there, so an identity registered by another wallet from the same seed with a different key layout still works and one whose keys this wallet does not hold is reported rather than restored as an identity that could never sign. Until the probe has concluded, and while a locked wallet keeps it from running, no new registration may start: it would burn an asset lock on an identity Platform refuses as a duplicate. Established contacts (a request proved in both directions) are restored by decrypting their requests, the friendship keychains are imported with birth times taken from our own on-chain requests (never the sender-authored document time), the outbound payment cursors are rebuilt from wallet history and a rescan starts. A contact skipped for a reason a later run can resolve (an unanswered page, a re-locked wallet) keeps the contact phase owed under a platform/recovery-pending record that the next start resumes. Every paged read is one request per step, continued from the cursor, under a flow-level cap. The network-changed and record-version wipes end in recovery through this path. Tests (test_dash-qt): recovery treats only proven absence as absence, never re-probes after a session ends and opens registration only then; an identity with foreign keys is refused and a locked wallet defers the probe to its unlock while blocking registration; an owed contact phase resumes on the next start without a second identity scan; the restored identity, name across two pages continued by cursor and key ids; mutual-only contacts with the birth time of our own request; the send entry looking up only what cannot be an address prefix while typing (and on leaving the entry otherwise), showing the DPNS label and derived address (never the profile name or the normalized label) with the cursor advancing exactly once on commit; and the recipient entry validator. An Identity details dialog shows the username, the identity id in Base58 (hex in its tooltip and context menu), its state, when this wallet registered it (not shown for a restored identity, whose record was written when it was found), its balance on Dash Platform in the display unit, its profile, and behind Show keys its keys with the ones this wallet holds marked, in cards sharing one label column. It reads once when opened; a failed read leaves its values at a dash instead of Loading… and offers Try again, and while DashPay is paused it reads nothing and says it shows only what the wallet knows. Its Copy buttons are secondary so Close is its one filled button. Contact errors and the picker's empty state say to add contacts on the DashPay tab. A recovery check that gets no answer ends in a notice with Try again instead of checking forever, and a locked wallet is offered Unlock wallet…, which unlocks it for that scan only and locks it again when the scan ends or after two minutes (a monotonic timer), whichever comes first. The scan derives the MASTER key hashes it probes before its first request; a scan whose unlock ran out goes on with its reads, and an identity it finds is not concluded on (its keys cannot be compared) until the next unlock, while a contact it cannot decrypt stays owed. The Send tab shows a username's lookup state on a line under the recipient; the @ button's glyph is sized through GUIUtil::setFont, and its Alt+C shortcut belongs to the entry being edited, so a second recipient does not make it ambiguous. The DashPay page gives its primary action the initial focus (the state card's action, else the empty state's Add contact…, else the contacts table), and Identity details… sits next to Edit profile… on the right of the header. A contact row whose reply Platform is confirming cannot be accepted again meanwhile. The contact picker hides its Profile name column when nobody has a profile and shares the text colour and selection of the contacts and search tables in both themes. Tests: the identity details after failed reads with no Network, Funding, Revision, Refresh or Copy all details and the keys behind Show keys (identityDetailsAfterFailedReads), Created only for an identity this wallet registered (identityDetailsCreatedOnlyWhenRegisteredHere), no reads while paused (identityDetailsPausedShowsLocalOnly), a connected contact's row pointing to Send with no action of its own (answeredRequestEstablishesContact) and the recovery unlock lasting only for the scan, with an identity found after it ran out left for the next unlock (recoveryUnlockLastsForTheScan). Recovery restores each contact from its newest incoming request (the one DashPay acts on, ContactFlow::NewestPerSender) and remembers every request this identity sent, answered or not, under contact/out/ with the time of the first one: that is the keychain's birth time and what tells Add contact a request was already sent. Past payments the rebuilt cursor finds in wallet history to a restored contact's payment addresses are labelled as payments to them, as a send from here labels them; only scripts the wallet holds a transaction for are labelled, and a label the user gave the address stays. The Identity details dialog no longer shows the normalized Stored as form of the username. Tests: recoveryRestoresIdentityAndPagesContacts also covers an unanswered request we sent being remembered (and one never sent not being), and a past payment to a restored contact labelled with the cursor continuing after it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 7004cfe commit a562eda

27 files changed

Lines changed: 4537 additions & 1497 deletions

‎src/Makefile.qt.include‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,16 @@ QT_MOC_CPP = \
125125
if ENABLE_PLATFORM_GUI
126126
QT_MOC_CPP += \
127127
qt/platform/moc_contactflow.cpp \
128+
qt/platform/moc_contactpickerdialog.cpp \
128129
qt/platform/moc_contactsmodel.cpp \
129130
qt/platform/moc_contactspage.cpp \
130131
qt/platform/moc_createusernamewizard.cpp \
131132
qt/platform/moc_dashpayoptionswidget.cpp \
133+
qt/platform/moc_identitydetailsdialog.cpp \
132134
qt/platform/moc_identityflow.cpp \
133135
qt/platform/moc_platformoptindialog.cpp \
134136
qt/platform/moc_platformpage.cpp \
137+
qt/platform/moc_platformrecovery.cpp \
135138
qt/platform/moc_platformservice.cpp \
136139
qt/platform/moc_platformui.cpp \
137140
qt/platform/moc_profiledialog.cpp \
@@ -243,13 +246,16 @@ BITCOIN_QT_H = \
243246
if ENABLE_PLATFORM_GUI
244247
BITCOIN_QT_H += \
245248
qt/platform/contactflow.h \
249+
qt/platform/contactpickerdialog.h \
246250
qt/platform/contactsmodel.h \
247251
qt/platform/contactspage.h \
248252
qt/platform/createusernamewizard.h \
249253
qt/platform/dashpayoptionswidget.h \
254+
qt/platform/identitydetailsdialog.h \
250255
qt/platform/identityflow.h \
251256
qt/platform/platformoptindialog.h \
252257
qt/platform/platformpage.h \
258+
qt/platform/platformrecovery.h \
253259
qt/platform/platformservice.h \
254260
qt/platform/platformui.h \
255261
qt/platform/profiledialog.h \
@@ -391,13 +397,16 @@ BITCOIN_QT_WALLET_CPP = \
391397

392398
BITCOIN_QT_PLATFORM_CPP = \
393399
qt/platform/contactflow.cpp \
400+
qt/platform/contactpickerdialog.cpp \
394401
qt/platform/contactsmodel.cpp \
395402
qt/platform/contactspage.cpp \
396403
qt/platform/createusernamewizard.cpp \
397404
qt/platform/dashpayoptionswidget.cpp \
405+
qt/platform/identitydetailsdialog.cpp \
398406
qt/platform/identityflow.cpp \
399407
qt/platform/platformoptindialog.cpp \
400408
qt/platform/platformpage.cpp \
409+
qt/platform/platformrecovery.cpp \
401410
qt/platform/platformservice.cpp \
402411
qt/platform/platformui.cpp \
403412
qt/platform/profiledialog.cpp \

‎src/qt/bitcoinaddressvalidator.cpp‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,31 @@ BitcoinAddressEntryValidator::BitcoinAddressEntryValidator(QObject *parent, bool
2222
{
2323
}
2424

25+
DashPayRecipientEntryValidator::DashPayRecipientEntryValidator(QObject* parent, bool allow_uri) :
26+
QValidator(parent),
27+
m_address_validator(nullptr, allow_uri)
28+
{
29+
}
30+
31+
QValidator::State DashPayRecipientEntryValidator::validate(QString& input, int& pos) const
32+
{
33+
const State address_state = m_address_validator.validate(input, pos);
34+
if (address_state != Invalid) return address_state;
35+
36+
// DPNS labels accept ASCII letters, digits, and interior hyphens. Keep
37+
// incomplete labels editable, but reject impossible candidates early.
38+
if (input.size() > 63 || input.startsWith('-')) return Invalid;
39+
for (const QChar ch : input) {
40+
const ushort c = ch.unicode();
41+
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '-')) {
42+
return Invalid;
43+
}
44+
}
45+
46+
if (input.size() < 3 || input.endsWith('-')) return Intermediate;
47+
return Acceptable;
48+
}
49+
2550
QValidator::State BitcoinAddressEntryValidator::validate(QString &input, int &pos) const
2651
{
2752
Q_UNUSED(pos);

‎src/qt/bitcoinaddressvalidator.h‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,26 @@ class BitcoinAddressEntryValidator : public QValidator
2323
bool fAllowURI;
2424
};
2525

26+
/** Recipient entry validator that also permits DashPay username candidates.
27+
*
28+
* This only controls which characters may be entered. A username is not a
29+
* payment destination until it has been proof-resolved and replaced with a
30+
* Dash address; BitcoinAddressCheckValidator remains responsible for that
31+
* final check.
32+
*/
33+
class DashPayRecipientEntryValidator : public QValidator
34+
{
35+
Q_OBJECT
36+
37+
public:
38+
explicit DashPayRecipientEntryValidator(QObject* parent, bool allow_uri = false);
39+
40+
State validate(QString& input, int& pos) const override;
41+
42+
private:
43+
BitcoinAddressEntryValidator m_address_validator;
44+
};
45+
2646
/** Bitcoin address widget validator, checks for a valid bitcoin address.
2747
*/
2848
class BitcoinAddressCheckValidator : public QValidator

‎src/qt/forms/sendcoinsentry.ui‎

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,25 @@
5454
</property>
5555
</widget>
5656
</item>
57+
<item>
58+
<widget class="QToolButton" name="contactsButton">
59+
<property name="visible">
60+
<bool>false</bool>
61+
</property>
62+
<property name="toolTip">
63+
<string>Choose a DashPay contact to pay (Alt+C)</string>
64+
</property>
65+
<property name="text">
66+
<string/>
67+
</property>
68+
<property name="iconSize">
69+
<size>
70+
<width>22</width>
71+
<height>22</height>
72+
</size>
73+
</property>
74+
</widget>
75+
</item>
5776
<item>
5877
<widget class="QToolButton" name="addressBookButton">
5978
<property name="toolTip">
@@ -110,7 +129,20 @@
110129
</item>
111130
</layout>
112131
</item>
113-
<item row="1" column="0">
132+
<item row="1" column="1">
133+
<widget class="QLabel" name="payToStatus">
134+
<property name="visible">
135+
<bool>false</bool>
136+
</property>
137+
<property name="textFormat">
138+
<enum>Qt::PlainText</enum>
139+
</property>
140+
<property name="wordWrap">
141+
<bool>true</bool>
142+
</property>
143+
</widget>
144+
</item>
145+
<item row="2" column="0">
114146
<widget class="QLabel" name="labellLabel">
115147
<property name="text">
116148
<string>&amp;Label:</string>
@@ -123,7 +155,7 @@
123155
</property>
124156
</widget>
125157
</item>
126-
<item row="1" column="1">
158+
<item row="2" column="1">
127159
<widget class="QLineEdit" name="addAsLabel">
128160
<property name="toolTip">
129161
<string>Enter a label for this address to add it to the list of used addresses</string>
@@ -133,7 +165,7 @@
133165
</property>
134166
</widget>
135167
</item>
136-
<item row="2" column="0">
168+
<item row="3" column="0">
137169
<widget class="QLabel" name="amountLabel">
138170
<property name="text">
139171
<string>A&amp;mount:</string>
@@ -146,7 +178,7 @@
146178
</property>
147179
</widget>
148180
</item>
149-
<item row="2" column="1">
181+
<item row="3" column="1">
150182
<layout class="QHBoxLayout" name="horizontalLayoutAmount" stretch="0,1,0">
151183
<item>
152184
<widget class="BitcoinAmountField" name="payAmount" native="true">
@@ -174,7 +206,7 @@
174206
</item>
175207
</layout>
176208
</item>
177-
<item row="3" column="0">
209+
<item row="4" column="0">
178210
<widget class="QLabel" name="messageLabel">
179211
<property name="text">
180212
<string>Message:</string>
@@ -184,7 +216,7 @@
184216
</property>
185217
</widget>
186218
</item>
187-
<item row="3" column="1">
219+
<item row="4" column="1">
188220
<widget class="QLabel" name="messageTextLabel">
189221
<property name="toolTip">
190222
<string>A message that was attached to the dash: URI which will be stored with the transaction for your reference. Note: This message will not be sent over the Dash network.</string>
@@ -194,7 +226,7 @@
194226
</property>
195227
</widget>
196228
</item>
197-
<item row="4" column="0" colspan="2">
229+
<item row="5" column="0" colspan="2">
198230
<widget class="Line" name="line">
199231
<property name="orientation">
200232
<enum>Qt::Horizontal</enum>
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Copyright (c) 2026 The Dash Core developers
2+
// Distributed under the MIT software license, see the accompanying
3+
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
4+
5+
#include <qt/platform/contactpickerdialog.h>
6+
7+
#include <qt/guiutil.h>
8+
#include <qt/platform/contactsmodel.h>
9+
#include <qt/platform/platformservice.h>
10+
#include <qt/platform/platformui.h>
11+
12+
#include <QDialogButtonBox>
13+
#include <QHeaderView>
14+
#include <QLabel>
15+
#include <QPushButton>
16+
#include <QSortFilterProxyModel>
17+
#include <QTableView>
18+
#include <QVBoxLayout>
19+
20+
ContactPickerDialog::ContactPickerDialog(PlatformService& service, QWidget* parent) :
21+
QDialog(parent, GUIUtil::dialog_flags),
22+
m_service(service)
23+
{
24+
setWindowTitle(tr("Pay a DashPay contact"));
25+
resize(440, 360);
26+
27+
auto* layout = new QVBoxLayout(this);
28+
layout->addWidget(PlatformUi::makeHint(tr("Choose who to pay. The payment goes to a fresh address that only you "
29+
"and this contact can link."),
30+
this));
31+
32+
m_model = new ContactsModel(m_service, this);
33+
m_proxy = new QSortFilterProxyModel(this);
34+
m_proxy->setSourceModel(m_model);
35+
m_proxy->setFilterRole(ContactsModel::KindRole);
36+
m_proxy->setFilterFixedString(QString::number(static_cast<int>(ContactsModel::Kind::Established)));
37+
m_proxy->setFilterKeyColumn(0);
38+
39+
m_view = new QTableView(this);
40+
m_view->setModel(m_proxy);
41+
m_view->setSelectionBehavior(QAbstractItemView::SelectRows);
42+
m_view->setSelectionMode(QAbstractItemView::SingleSelection);
43+
m_view->setEditTriggers(QAbstractItemView::NoEditTriggers);
44+
m_view->horizontalHeader()->setStretchLastSection(true);
45+
m_view->verticalHeader()->hide();
46+
m_view->hideColumn(ContactsModel::Direction);
47+
layout->addWidget(m_view);
48+
49+
m_empty = new QWidget(this);
50+
auto* empty_layout = new QVBoxLayout(m_empty);
51+
auto* empty_title = new QLabel(tr("No contacts to pay yet."), m_empty);
52+
empty_title->setAlignment(Qt::AlignCenter);
53+
auto* empty_hint = PlatformUi::makeHint(tr("Add contacts on the DashPay tab. Once they accept, you can pay them "
54+
"here by username."),
55+
m_empty);
56+
empty_hint->setAlignment(Qt::AlignCenter);
57+
empty_layout->addStretch();
58+
empty_layout->addWidget(empty_title);
59+
empty_layout->addWidget(empty_hint);
60+
empty_layout->addStretch();
61+
layout->addWidget(m_empty);
62+
m_loading = new QWidget(this);
63+
auto* loading_layout = new QVBoxLayout(m_loading);
64+
auto* loading_bar = PlatformUi::makeBusyBar(m_loading);
65+
loading_bar->show();
66+
auto* loading_text = PlatformUi::makeHint(tr("Loading contacts…"), m_loading);
67+
loading_text->setAlignment(Qt::AlignCenter);
68+
loading_layout->addStretch();
69+
loading_layout->addWidget(loading_bar);
70+
loading_layout->addWidget(loading_text);
71+
loading_layout->addStretch();
72+
layout->addWidget(m_loading);
73+
74+
auto* buttons = new QDialogButtonBox(this);
75+
auto* cancel = buttons->addButton(QDialogButtonBox::Cancel);
76+
PlatformUi::makeSecondary(cancel);
77+
m_choose_button = buttons->addButton(tr("Pay"), QDialogButtonBox::AcceptRole);
78+
m_choose_button->setDefault(true);
79+
layout->addWidget(buttons);
80+
setTabOrder(m_view, m_choose_button);
81+
setTabOrder(m_choose_button, cancel);
82+
83+
connect(buttons, &QDialogButtonBox::accepted, this, &ContactPickerDialog::chooseCurrent);
84+
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
85+
connect(m_view, &QTableView::activated, this, &ContactPickerDialog::chooseCurrent);
86+
connect(m_view->selectionModel(), &QItemSelectionModel::currentChanged, this, &ContactPickerDialog::updateState);
87+
connect(m_proxy, &QAbstractItemModel::modelReset, this, &ContactPickerDialog::updateState);
88+
connect(m_proxy, &QAbstractItemModel::rowsInserted, this, &ContactPickerDialog::updateState);
89+
connect(m_proxy, &QAbstractItemModel::rowsRemoved, this, &ContactPickerDialog::updateState);
90+
91+
GUIUtil::updateFonts();
92+
GUIUtil::disableMacFocusRect(this);
93+
updateState();
94+
m_model->refresh();
95+
}
96+
97+
void ContactPickerDialog::updateState()
98+
{
99+
const bool have_contacts{m_proxy->rowCount() > 0};
100+
// No column of blanks when nobody has a profile.
101+
m_view->setColumnHidden(ContactsModel::DisplayName, !m_model->hasDisplayNames());
102+
m_view->setVisible(have_contacts);
103+
m_empty->setVisible(!have_contacts && m_model->loaded());
104+
m_loading->setVisible(!have_contacts && !m_model->loaded());
105+
// The first contact is ready to pay: Enter pays it.
106+
if (have_contacts && !m_view->currentIndex().isValid()) {
107+
m_view->setCurrentIndex(m_proxy->index(0, 0));
108+
m_view->setFocus();
109+
}
110+
const QModelIndex current{m_view->currentIndex()};
111+
m_choose_button->setEnabled(current.isValid() &&
112+
!m_proxy->data(current, ContactsModel::UsernameRole).toString().isEmpty());
113+
}
114+
115+
void ContactPickerDialog::chooseCurrent()
116+
{
117+
const QModelIndex current{m_view->currentIndex()};
118+
if (!current.isValid()) return;
119+
const QString username{m_proxy->data(current, ContactsModel::UsernameRole).toString()};
120+
if (username.isEmpty()) return;
121+
m_selected_username = username;
122+
accept();
123+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright (c) 2026 The Dash Core developers
2+
// Distributed under the MIT software license, see the accompanying
3+
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
4+
5+
#ifndef BITCOIN_QT_PLATFORM_CONTACTPICKERDIALOG_H
6+
#define BITCOIN_QT_PLATFORM_CONTACTPICKERDIALOG_H
7+
8+
#include <QDialog>
9+
#include <QString>
10+
11+
class ContactsModel;
12+
class PlatformService;
13+
14+
QT_BEGIN_NAMESPACE
15+
class QPushButton;
16+
class QSortFilterProxyModel;
17+
class QTableView;
18+
QT_END_NAMESPACE
19+
20+
/** Modal picker over the established (payable) contacts. On accept,
21+
* selectedUsername() holds the chosen contact's DashPay username. */
22+
class ContactPickerDialog : public QDialog
23+
{
24+
Q_OBJECT
25+
26+
public:
27+
explicit ContactPickerDialog(PlatformService& service, QWidget* parent = nullptr);
28+
29+
QString selectedUsername() const { return m_selected_username; }
30+
31+
private Q_SLOTS:
32+
void updateState();
33+
void chooseCurrent();
34+
35+
private:
36+
PlatformService& m_service;
37+
ContactsModel* m_model{nullptr};
38+
QSortFilterProxyModel* m_proxy{nullptr};
39+
QTableView* m_view{nullptr};
40+
QWidget* m_empty{nullptr};
41+
QWidget* m_loading{nullptr};
42+
QPushButton* m_choose_button{nullptr};
43+
QString m_selected_username;
44+
};
45+
46+
#endif // BITCOIN_QT_PLATFORM_CONTACTPICKERDIALOG_H

0 commit comments

Comments
 (0)