Skip to content

Commit c06b4e9

Browse files
feat(qt): DashPay profiles and contacts
Contacts run over DIP-15 as the mobile wallets implement it. Sending a request derives our receiving keychain for the contact, serializes it in the 69-byte compact form (parent fingerprint, chain code, public key), has the wallet compute the ECDH secret between our ENCRYPTION key and the recipient key the SDK's mint-side policy selects, and the accountReference MAC over the compact xpub, and hands only those 32-byte outputs to build_contact_request, which encrypts and assembles the document. The rotation version of a resend comes from the chain: our latest request to that contact is unmasked with our MAC and its version bumped, so the unique (ownerId, toUserId, accountReference) index cannot reject it and nothing is lost on seed recovery. The request is confirmed by a proved re-query of our sent requests, repeated with a backoff for three minutes. A request Platform accepted for broadcast is never reported as not sent: the UI says it was sent and is being confirmed, the contacts list shows it as a sent request, and a confirmation that takes longer hands over to the contacts refresh; only a typed refusal is a failure. A new contact request waits while one is being confirmed or while the profile signed at registration still holds the identity's first DashPay nonce. Accepting a request checks the sender and recipient key purposes against the SDK's receive policy and never runs ECDH with the MASTER key (a request from a wallet too old to have encryption keys is refused with a visible reason), decrypts the compact xpub with our key at recipientKeyIndex through dip15_decrypt_xpub, validates it, imports our receiving keychain with a rescan birth time that is ours (the time of our own confirmed request, or now on a first accept, never the counterparty's document time), labels the chain for transaction history, stores the contact's xpub and sends the reciprocal request, all under a single wallet unlock. A contact is established only once both directions are on chain and its key is imported. A request that answers ours establishes the contact with no broadcast, as the mobile wallets do: a refresh or an unlock decrypts it and imports the keychains without asking for the passphrase. Until then the row is Accepted: it asks for an unlock only while the wallet is locked, otherwise it shows why finishing failed, and a request that cannot be read is not retried on every refresh. Profiles are a display name and a public message, no avatar: the profile is read (proved) before a replace so every field another wallet set is carried through, and the update is confirmed only by a proved re-read at the next revision. Contact metadata (username, profile name) is cached from proved reads only, and a change is shown by rebuilding the rows from the records it was written to, without reading the contact requests again; a profile name is always shown as an untrusted profile name, never as a verified identity. The dashboard gains the contacts list with accept, Add contact… (a username lookup that sends a contact request), and the profile dialog. Tests (test_dash-qt over FakePlatformClient and a real descriptor wallet): decryption with the ECDH secret the wallet derives, the MASTER-key and wrong-purpose refusals, the full accept with a birth time no earlier than our own request and the labelled receiving chain, accepting after our own request sending nothing (contactAcceptAfterOurRequestSendsNothing), one passphrase prompt per accept (contactAcceptAsksToUnlockOnce), an answered request established on unlock with no broadcast and the Accepted row state (answeredRequestEstablishesContact), an answered request that cannot be read showing why without the unlock wording and not being retried (answeredRequestThatCannotFinishSaysWhy), the resend bumping the on-chain version with the ENCRYPTION sender key and the SDK-selected recipient key, the profile replace carrying the existing document and confirmed by proof, an accepted request reported as sent and being confirmed rather than failed (contactRequestConfirmationIsNeverAFailure), search results carrying the proved profile name read once a session, and contact metadata shown without re-reading the requests (contactMetadataShownWithoutRereadingRequests). The profile dialog cannot be edited or saved before the current profile has loaded, since a save would publish empty fields over it; saves, searches and contact requests show a busy bar. The contacts section puts the selected row's actions next to Add contact… in its header, sizes its table to its rows (three to twelve, then it scrolls) with the columns as wide as their content and Status next to the data, hides an empty Profile name column, has a loading state and a compact empty state that says contacts are paid by username from the Send tab, clears success messages after a few seconds, and offers Ignore / Hide contact, kept on this wallet under contact/hidden/ because a contact request can be neither rejected nor withdrawn on Platform. Rows and search results act on a double click or Enter, never on the single click some platforms activate rows with, since both write to Platform. The Add a contact dialog lays out Close and Send contact request itself so the primary stays last on every platform, keeps its column widths from one search to the next, and shows each result's proved profile name, read once a session (at most one page of 25). The profile dialog keeps each label on the line of its field, gives both character counters the width of the longest count so the name field and the message box end at the same edge, and grows to show a whole error. The light and dark themes give the contacts table a text colour, and the contacts and search tables one visible selection. The contacts list is not read while the node has pushed no evonode endpoints (network activity off, syncing, or not pushed again yet after a pause): a refresh asked for then, or while one is running, runs once they arrive or it ends, and a read that failed because the endpoints went away is not reported. The dashboard header puts Edit profile… to the right of the name block, top-aligned, and a success message there clears itself; a failed profile read is retried once 30 s later while the tab is shown (one pending retry, like the credits). Tests: the profile dialog waits for the loaded profile and lines its fields up, an ignored request leaves the list until shown again or asked, and turning network activity off and on shows no contacts error while the endpoints are not back and clears one when they are (contactsWaitForEndpointsOnResume). There is no Refresh: the list reads itself again when the dashboard is shown, on a new ChainLock while it is shown (at most once a minute), on a five-minute fallback, and after the user's own changes (a request sent or accepted, a profile saved); a failed read is tried again after 30 s, 60 s, 2, 4 and then every 10 minutes, and its error line offers Try again. Last updated at … shows under the list only while it may be out of date. Nothing is read while the list is hidden, paused or without endpoints. The dashboard has one filled button: the selected row's Accept (or Unlock to finish, Try again) while it needs an answer, otherwise Add contact… (the empty state's own while there are no contacts), and none while the registration card holds the page's action. With requests waiting, the first is selected when the list is shown, without taking the focus, so its Accept is visible at once. Add a contact asks for a Username (buddy label, placeholder Their DashPay username), says the answering evonode sees what is looked up, and looks nothing up before three characters, the shortest username. Edit profile… waits for the profile to be read, and a failed read offers Add profile…, disabled until one succeeds. A connected contact's tooltip says to pay them from Send by typing their username or pressing @. Tests: the dashboard offers no Send, Disable, Refresh or Find people and offers Add contact… (dashboardHasNoSendDisableOrRefresh); one filled button in each registration and contacts state (onlyOneFilledButton); ChainLock reads throttled to one a minute, the failure backoff and its reset, Last updated only while stale, nothing read while hidden (contactsRefreshFollowsChainLocks); showing the tab twice within 30 s reads once (showRefreshThrottled); the first waiting request selected with a filled Accept (firstIncomingRequestSelected); nothing looked up under three characters (addContactNeedsThreeCharacters). A contact that sends again (a DIP-15 re-send, say with new payment addresses) is one row, and only its newest request counts: requests are ranked by createdAt and then accountReference, as the mobile wallets rank them, so both ends pay the same chain. An established contact whose newest request is not the one it was established from is re-established from it without a broadcast, and a changed xpub restarts its payment cursor at the first address. A new request (or a newer one from a known sender) reads the contact's username and profile again, and otherwise every contacts refresh re-reads them once five minutes have passed, so a profile edit shows within minutes. Add contact reads the chosen result's identity once a session (a send reads it too) and marks one with no key a contact request can be encrypted to as Can't receive contact requests; a request we sent, on chain, recorded by this wallet or being confirmed, reads as Request sent; the Note column shows only when a row has a note. Tests: a re-send replaces the earlier request, is re-established from its xpub with the cursor restarted, and listing the same requests again changes nothing (contactResendUsesNewestRequest); an identity that cannot receive is marked, cannot be sent to and is read once (addContactMarksIdentitiesThatCannotReceive). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent c10b24b commit c06b4e9

24 files changed

Lines changed: 4815 additions & 27 deletions

‎src/Makefile.qt.include‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,13 +124,18 @@ QT_MOC_CPP = \
124124

125125
if ENABLE_PLATFORM_GUI
126126
QT_MOC_CPP += \
127+
qt/platform/moc_contactflow.cpp \
128+
qt/platform/moc_contactsmodel.cpp \
129+
qt/platform/moc_contactspage.cpp \
127130
qt/platform/moc_createusernamewizard.cpp \
128131
qt/platform/moc_dashpayoptionswidget.cpp \
129132
qt/platform/moc_identityflow.cpp \
130133
qt/platform/moc_platformoptindialog.cpp \
131134
qt/platform/moc_platformpage.cpp \
132135
qt/platform/moc_platformservice.cpp \
133-
qt/platform/moc_platformui.cpp
136+
qt/platform/moc_platformui.cpp \
137+
qt/platform/moc_profiledialog.cpp \
138+
qt/platform/moc_usernamesearchdialog.cpp
134139
endif
135140

136141
BITCOIN_MM = \
@@ -237,13 +242,18 @@ BITCOIN_QT_H = \
237242

238243
if ENABLE_PLATFORM_GUI
239244
BITCOIN_QT_H += \
245+
qt/platform/contactflow.h \
246+
qt/platform/contactsmodel.h \
247+
qt/platform/contactspage.h \
240248
qt/platform/createusernamewizard.h \
241249
qt/platform/dashpayoptionswidget.h \
242250
qt/platform/identityflow.h \
243251
qt/platform/platformoptindialog.h \
244252
qt/platform/platformpage.h \
245253
qt/platform/platformservice.h \
246-
qt/platform/platformui.h
254+
qt/platform/platformui.h \
255+
qt/platform/profiledialog.h \
256+
qt/platform/usernamesearchdialog.h
247257
endif
248258

249259
QT_RES_ICONS = \
@@ -380,13 +390,18 @@ BITCOIN_QT_WALLET_CPP = \
380390
qt/walletview.cpp
381391

382392
BITCOIN_QT_PLATFORM_CPP = \
393+
qt/platform/contactflow.cpp \
394+
qt/platform/contactsmodel.cpp \
395+
qt/platform/contactspage.cpp \
383396
qt/platform/createusernamewizard.cpp \
384397
qt/platform/dashpayoptionswidget.cpp \
385398
qt/platform/identityflow.cpp \
386399
qt/platform/platformoptindialog.cpp \
387400
qt/platform/platformpage.cpp \
388401
qt/platform/platformservice.cpp \
389-
qt/platform/platformui.cpp
402+
qt/platform/platformui.cpp \
403+
qt/platform/profiledialog.cpp \
404+
qt/platform/usernamesearchdialog.cpp
390405

391406
BITCOIN_QT_CPP = $(BITCOIN_QT_BASE_CPP)
392407
if TARGET_WINDOWS

‎src/qt/platform/contactflow.cpp‎

Lines changed: 583 additions & 0 deletions
Large diffs are not rendered by default.

‎src/qt/platform/contactflow.h‎

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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_CONTACTFLOW_H
6+
#define BITCOIN_QT_PLATFORM_CONTACTFLOW_H
7+
8+
#include <platform/types.h>
9+
#include <wallet/platformtypes.h>
10+
11+
#include <QObject>
12+
#include <QString>
13+
14+
#include <cstdint>
15+
#include <optional>
16+
#include <string>
17+
#include <vector>
18+
19+
class PlatformService;
20+
21+
/**
22+
* Sends a DashPay contact request and imports the resulting friendship
23+
* keychains so payments to/from the contact are tracked by the wallet.
24+
*
25+
* A contact request carries our DIP-15 receiving xpub in its 69-byte
26+
* compact form, encrypted by the SDK with the ECDH secret the wallet
27+
* derives between our ENCRYPTION key and the recipient's DECRYPTION (or
28+
* ENCRYPTION) key, plus the DIP-15 accountReference masked with the MAC the
29+
* wallet computes over that xpub. Our receiving chain is imported before
30+
* the request is sent, so the recipient can pay as soon as they accept.
31+
* Accepting an incoming request decrypts and stores their xpub for outbound
32+
* payments, then sends our request back unless ours is already on chain.
33+
* A request that answers ours establishes the contact without any
34+
* broadcast, as the mobile wallets do.
35+
*
36+
* The key work of an operation (decryption, keychain derivation, MAC, ECDH,
37+
* signature) runs under one wallet unlock, released before every network
38+
* wait; the identity keys used are those the proved identity carries at
39+
* the ids the identity record names.
40+
*
41+
* Per-contact state is persisted under the "contact/…" platform data
42+
* records; the request documents themselves live on Platform.
43+
*/
44+
class ContactFlow : public QObject
45+
{
46+
Q_OBJECT
47+
48+
public:
49+
explicit ContactFlow(PlatformService& service, QObject* parent = nullptr);
50+
51+
//! The one request per sender DashPay acts on, in first-seen order: the
52+
//! latest, then the highest accountReference (whose top four bits are
53+
//! the DIP-15 version), as the mobile wallets choose it. A sender sends
54+
//! again to replace its payment addresses, and Platform keeps every
55+
//! request.
56+
static std::vector<platform::ContactRequest> NewestPerSender(const std::vector<platform::ContactRequest>& requests);
57+
58+
//! Send a contact request to a proved identity. The recipient key is
59+
//! chosen by the SDK's mint-side policy. Emits requestSent/requestFailed.
60+
void sendRequest(const platform::Identity& recipient);
61+
62+
//! Accept an incoming request: verify the sender identity, decrypt the
63+
//! sender's xpub, import the keychains and send a request back unless
64+
//! ours is already on chain.
65+
void accept(const platform::ContactRequest& incoming);
66+
67+
//! Establish a contact whose request answers ours, without asking for
68+
//! the passphrase (the wallet must be unlocked already) and without any
69+
//! broadcast. Emits acceptedCompleted.
70+
void completeAccepted(const platform::ContactRequest& incoming);
71+
72+
//! Decrypt the compact xpub of an incoming request with our key at its
73+
//! recipient_key_index, which must be our ENCRYPTION or DECRYPTION key,
74+
//! after checking both key purposes against the DIP-15 receive policy.
75+
//! sender is the proved sender identity.
76+
std::optional<wallet::CompactXpub> decryptXpub(const platform::ContactRequest& incoming,
77+
const platform::Identity& sender, QString& error);
78+
79+
//! Import the contact's sending chain as our receiving keychain and
80+
//! validate their xpub. birth_time (unix seconds) bounds later rescans;
81+
//! it is the time of OUR own request, never the counterparty's document
82+
//! time, which the sender controls.
83+
bool importKeychains(const platform::Identifier& their_identity, const wallet::CompactXpub& their_xpub,
84+
int64_t birth_time, QString& error);
85+
86+
Q_SIGNALS:
87+
//! Platform accepted the request for broadcast; it is being confirmed.
88+
void requestPending(const QString& to_identity_hex);
89+
//! The request is confirmed on Platform (or the contact established
90+
//! without one).
91+
void requestSent(const QString& to_identity_hex);
92+
//! Platform did not confirm the request it accepted within the
93+
//! confirmation window; the contacts refresh shows it once it does.
94+
void requestUnconfirmed(const QString& to_identity_hex);
95+
//! The request was not sent, or Platform refused it.
96+
void requestFailed(const QString& to_identity_hex, const QString& error, const QString& details);
97+
//! error is empty once the contact is established; otherwise
98+
//! `retryable` says whether a later attempt can succeed on its own (an
99+
//! unanswered read, a wallet locked in the meantime) or the request
100+
//! itself was refused.
101+
void acceptedCompleted(const QString& identity_hex, const QString& error, bool retryable);
102+
103+
private:
104+
//! Send our request; when accepting, their request is established under
105+
//! the same unlock.
106+
void sendRequest(const platform::Identity& recipient, std::optional<platform::ContactRequest> accepting);
107+
//! When our own request to an identity was created, if we sent one.
108+
std::optional<int64_t> ourRequestTime(const platform::Identifier& to_identity) const;
109+
//! Decrypt an incoming request, import the keychains and store their xpub.
110+
bool establish(const platform::ContactRequest& incoming, const platform::Identity& sender, int64_t birth_time,
111+
QString& error);
112+
//! establish() for a request that answers ours, sent at our_request_time.
113+
bool establishAnswered(const platform::ContactRequest& incoming, const platform::Identity& sender,
114+
int64_t our_request_time, QString& error);
115+
bool prepareReceivingKeychain(const platform::Identifier& their_identity, wallet::FriendshipXpub& xpub,
116+
int64_t birth_time, QString& error);
117+
//! The account references of our requests to a contact on chain, page
118+
//! by page: the next request's rotation version is one above the latest
119+
//! of them (unmasked with our MAC), or 0 for a first request.
120+
void collectOurRequests(const platform::Identity& recipient, std::optional<platform::ContactRequest> accepting,
121+
const platform::Identifier& start_after, std::vector<uint32_t> account_references,
122+
int pages_left);
123+
//! Reads our proved identity and DashPay contract nonce, then signs.
124+
void buildAndBroadcast(const platform::Identity& recipient, std::optional<platform::ContactRequest> accepting,
125+
std::vector<uint32_t> account_references);
126+
void signAndBroadcast(const platform::Identity& sender, const platform::Identity& recipient,
127+
const std::optional<platform::ContactRequest>& accepting,
128+
const std::vector<uint32_t>& account_references, uint64_t nonce);
129+
//! Confirms the request just broadcast by its account reference among
130+
//! our requests created since since_ms, page by page, repeating the
131+
//! query with a growing interval until deadline (unix seconds).
132+
void confirmRequest(const platform::Identifier& to_identity, uint32_t account_reference, uint64_t since_ms,
133+
const platform::Identifier& start_after, int pages_left, int64_t deadline, int interval_ms);
134+
void fail(const platform::Identifier& to_identity, const QString& error, const QString& details = {});
135+
//! fail() with the plain-language text for a failed client call.
136+
void fail(const platform::Identifier& to_identity, const platform::Status& status, const QString& operation);
137+
138+
PlatformService& m_service;
139+
};
140+
141+
#endif // BITCOIN_QT_PLATFORM_CONTACTFLOW_H

0 commit comments

Comments
 (0)