Skip to content

Commit 8f376a6

Browse files
committed
Issue 15: add wallet-backed address book model and send label autofill
1 parent aaec5ae commit 8f376a6

File tree

8 files changed

+710
-0
lines changed

8 files changed

+710
-0
lines changed

qml/models/addressbookmodel.cpp

Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
// Copyright (c) 2026 The Bitcoin 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 <qml/models/addressbookmodel.h>
6+
7+
#include <key_io.h>
8+
#include <wallet/types.h>
9+
10+
#include <algorithm>
11+
#include <vector>
12+
13+
namespace {
14+
class WalletAddressBookDataSource final : public AddressBookDataSource
15+
{
16+
public:
17+
explicit WalletAddressBookDataSource(interfaces::Wallet& wallet)
18+
: m_wallet(wallet)
19+
{
20+
}
21+
22+
std::vector<interfaces::WalletAddress> getAddresses() override
23+
{
24+
return m_wallet.getAddresses();
25+
}
26+
27+
bool setAddressBook(const CTxDestination& dest,
28+
const std::string& label,
29+
const std::optional<wallet::AddressPurpose>& purpose) override
30+
{
31+
return m_wallet.setAddressBook(dest, label, purpose);
32+
}
33+
34+
bool delAddressBook(const CTxDestination& dest) override
35+
{
36+
return m_wallet.delAddressBook(dest);
37+
}
38+
39+
bool getAddress(const CTxDestination& dest,
40+
std::string* label,
41+
wallet::AddressPurpose* purpose) override
42+
{
43+
return m_wallet.getAddress(dest, label, nullptr, purpose);
44+
}
45+
46+
private:
47+
interfaces::Wallet& m_wallet;
48+
};
49+
} // namespace
50+
51+
AddressBookModel::AddressBookModel(std::unique_ptr<AddressBookDataSource> source, QObject* parent)
52+
: QAbstractListModel(parent), m_source(std::move(source))
53+
{
54+
refresh();
55+
}
56+
57+
AddressBookModel::AddressBookModel(interfaces::Wallet& wallet, QObject* parent)
58+
: AddressBookModel(std::make_unique<WalletAddressBookDataSource>(wallet), parent)
59+
{
60+
}
61+
62+
AddressBookModel::AddressBookModel(QObject* parent)
63+
: QAbstractListModel(parent)
64+
{
65+
}
66+
67+
int AddressBookModel::rowCount(const QModelIndex& parent) const
68+
{
69+
if (parent.isValid()) {
70+
return 0;
71+
}
72+
73+
return static_cast<int>(m_entries.size());
74+
}
75+
76+
QVariant AddressBookModel::data(const QModelIndex& index, int role) const
77+
{
78+
if (!index.isValid() || index.row() < 0 || index.row() >= rowCount()) {
79+
return {};
80+
}
81+
82+
const Entry& entry = m_entries.at(index.row());
83+
switch (role) {
84+
case Address:
85+
return entry.address;
86+
case Label:
87+
return entry.label;
88+
case Purpose:
89+
return PurposeName(entry.purpose);
90+
case IsMine:
91+
return entry.is_mine;
92+
default:
93+
return {};
94+
}
95+
}
96+
97+
QHash<int, QByteArray> AddressBookModel::roleNames() const
98+
{
99+
return {
100+
{Address, "address"},
101+
{Label, "label"},
102+
{Purpose, "purpose"},
103+
{IsMine, "isMine"},
104+
};
105+
}
106+
107+
QString AddressBookModel::lastError() const
108+
{
109+
return m_last_error;
110+
}
111+
112+
void AddressBookModel::refresh()
113+
{
114+
const int previous_count = rowCount();
115+
116+
if (!m_source) {
117+
if (!m_entries.empty()) {
118+
beginResetModel();
119+
m_entries.clear();
120+
endResetModel();
121+
}
122+
if (previous_count != 0) {
123+
Q_EMIT countChanged();
124+
}
125+
return;
126+
}
127+
128+
const std::vector<interfaces::WalletAddress> addresses = m_source->getAddresses();
129+
130+
std::vector<Entry> refreshed_entries;
131+
refreshed_entries.reserve(addresses.size());
132+
133+
for (const auto& address : addresses) {
134+
if (address.purpose == wallet::AddressPurpose::REFUND) {
135+
continue;
136+
}
137+
138+
const QString encoded = QString::fromStdString(EncodeDestination(address.dest));
139+
if (encoded.isEmpty()) {
140+
continue;
141+
}
142+
143+
refreshed_entries.push_back(Entry{
144+
encoded,
145+
QString::fromStdString(address.name),
146+
address.purpose,
147+
address.is_mine != wallet::ISMINE_NO,
148+
});
149+
}
150+
151+
std::sort(refreshed_entries.begin(), refreshed_entries.end(), [](const Entry& lhs, const Entry& rhs) {
152+
const int label_order = QString::compare(lhs.label, rhs.label, Qt::CaseInsensitive);
153+
if (label_order != 0) {
154+
return label_order < 0;
155+
}
156+
return lhs.address < rhs.address;
157+
});
158+
159+
beginResetModel();
160+
m_entries = std::move(refreshed_entries);
161+
endResetModel();
162+
163+
if (previous_count != rowCount()) {
164+
Q_EMIT countChanged();
165+
}
166+
}
167+
168+
bool AddressBookModel::addContact(const QString& address, const QString& label, const QString& purpose)
169+
{
170+
CTxDestination destination;
171+
if (!validateAddress(address, destination)) {
172+
return false;
173+
}
174+
175+
std::string existing_label;
176+
wallet::AddressPurpose existing_purpose{wallet::AddressPurpose::SEND};
177+
if (lookupAddress(destination, &existing_label, &existing_purpose)) {
178+
setLastError(tr("The entered address is already in the address book with label \"%1\".")
179+
.arg(QString::fromStdString(existing_label)));
180+
return false;
181+
}
182+
183+
const auto parsed_purpose = ParsePurpose(purpose);
184+
if (!parsed_purpose.has_value()) {
185+
setLastError(tr("Unsupported address purpose: %1").arg(purpose));
186+
return false;
187+
}
188+
189+
if (!applyContact(destination,
190+
label,
191+
parsed_purpose.value(),
192+
tr("Failed to save the contact."))) {
193+
return false;
194+
}
195+
196+
setLastError(QString{});
197+
return true;
198+
}
199+
200+
bool AddressBookModel::editContact(const QString& old_address,
201+
const QString& new_address,
202+
const QString& label,
203+
const QString& purpose)
204+
{
205+
CTxDestination old_destination;
206+
if (!validateAddress(old_address, old_destination)) {
207+
return false;
208+
}
209+
210+
CTxDestination new_destination;
211+
if (!validateAddress(new_address, new_destination)) {
212+
return false;
213+
}
214+
215+
std::string old_label;
216+
wallet::AddressPurpose old_purpose{wallet::AddressPurpose::SEND};
217+
if (!lookupAddress(old_destination, &old_label, &old_purpose)) {
218+
setLastError(tr("The selected contact does not exist."));
219+
return false;
220+
}
221+
222+
std::string existing_new_label;
223+
wallet::AddressPurpose existing_new_purpose{wallet::AddressPurpose::SEND};
224+
if (old_destination != new_destination
225+
&& lookupAddress(new_destination, &existing_new_label, &existing_new_purpose)) {
226+
setLastError(tr("The entered address is already in the address book with label \"%1\".")
227+
.arg(QString::fromStdString(existing_new_label)));
228+
return false;
229+
}
230+
231+
const auto parsed_purpose = ParsePurpose(purpose);
232+
if (!parsed_purpose.has_value()) {
233+
setLastError(tr("Unsupported address purpose: %1").arg(purpose));
234+
return false;
235+
}
236+
237+
if (!applyContact(new_destination,
238+
label,
239+
parsed_purpose.value(),
240+
tr("Failed to update the contact."))) {
241+
return false;
242+
}
243+
244+
if (old_destination != new_destination && !m_source->delAddressBook(old_destination)) {
245+
setLastError(tr("Updated the contact, but failed to remove the old address entry."));
246+
refresh();
247+
return false;
248+
}
249+
250+
refresh();
251+
setLastError(QString{});
252+
return true;
253+
}
254+
255+
bool AddressBookModel::deleteContact(const QString& address)
256+
{
257+
CTxDestination destination;
258+
if (!validateAddress(address, destination)) {
259+
return false;
260+
}
261+
262+
if (!m_source || !m_source->delAddressBook(destination)) {
263+
setLastError(tr("Failed to delete the selected contact."));
264+
return false;
265+
}
266+
267+
refresh();
268+
setLastError(QString{});
269+
return true;
270+
}
271+
272+
QString AddressBookModel::labelForAddress(const QString& address) const
273+
{
274+
const auto it = std::find_if(m_entries.begin(), m_entries.end(), [&](const Entry& entry) {
275+
return entry.address == address;
276+
});
277+
278+
if (it == m_entries.end()) {
279+
return {};
280+
}
281+
282+
return it->label;
283+
}
284+
285+
std::optional<wallet::AddressPurpose> AddressBookModel::ParsePurpose(const QString& purpose)
286+
{
287+
const QString normalized = purpose.trimmed().toLower();
288+
if (normalized == QStringLiteral("send")) {
289+
return wallet::AddressPurpose::SEND;
290+
}
291+
if (normalized == QStringLiteral("receive")) {
292+
return wallet::AddressPurpose::RECEIVE;
293+
}
294+
if (normalized == QStringLiteral("refund")) {
295+
return wallet::AddressPurpose::REFUND;
296+
}
297+
return std::nullopt;
298+
}
299+
300+
QString AddressBookModel::PurposeName(const wallet::AddressPurpose purpose)
301+
{
302+
switch (purpose) {
303+
case wallet::AddressPurpose::SEND:
304+
return QStringLiteral("send");
305+
case wallet::AddressPurpose::RECEIVE:
306+
return QStringLiteral("receive");
307+
case wallet::AddressPurpose::REFUND:
308+
return QStringLiteral("refund");
309+
}
310+
311+
return QStringLiteral("unknown");
312+
}
313+
314+
bool AddressBookModel::validateAddress(const QString& address, CTxDestination& destination)
315+
{
316+
const QString trimmed = address.trimmed();
317+
if (trimmed.isEmpty()) {
318+
setLastError(tr("Enter a Bitcoin address."));
319+
return false;
320+
}
321+
322+
destination = DecodeDestination(trimmed.toStdString());
323+
if (!IsValidDestination(destination)) {
324+
setLastError(tr("The entered address is not a valid Bitcoin address."));
325+
return false;
326+
}
327+
328+
return true;
329+
}
330+
331+
bool AddressBookModel::lookupAddress(const CTxDestination& destination,
332+
std::string* label,
333+
wallet::AddressPurpose* purpose)
334+
{
335+
if (!m_source) {
336+
return false;
337+
}
338+
339+
return m_source->getAddress(destination, label, purpose);
340+
}
341+
342+
void AddressBookModel::setLastError(const QString& error)
343+
{
344+
if (m_last_error == error) {
345+
return;
346+
}
347+
348+
m_last_error = error;
349+
Q_EMIT lastErrorChanged();
350+
}
351+
352+
bool AddressBookModel::applyContact(const CTxDestination& destination,
353+
const QString& label,
354+
const wallet::AddressPurpose purpose,
355+
const QString& backend_error)
356+
{
357+
if (!m_source) {
358+
setLastError(tr("Contact storage is unavailable."));
359+
return false;
360+
}
361+
362+
if (!m_source->setAddressBook(destination, label.trimmed().toStdString(), purpose)) {
363+
setLastError(backend_error);
364+
return false;
365+
}
366+
367+
refresh();
368+
return true;
369+
}
370+

0 commit comments

Comments
 (0)