Skip to content

Commit fc6dfca

Browse files
committed
Issue 19: add BIP21 send URI import/open/file/drop flows
1 parent c8f5618 commit fc6dfca

15 files changed

+1152
-2
lines changed

doc/test-automation-selectors.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ It supports the parity backlog Definition of Done (`DefinitionOfDone.md`) requir
2222
## Current Examples
2323

2424
- Receive flow: `receiveAmountInput`, `receiveLabelInput`, `receiveContactSelectButton`, `receiveContactsPopup`, `receiveContactsSearchInput`, `receiveContactRow`, `receiveContactSelectFirstActionButton`, `receiveContactUseButton`, `receiveCreateAddressButton`, `receiveCopySelectedUriButton`, `receiveQrImage`
25-
- Send flow: `sendAddressInput`, `sendOpenContactsButton`, `sendContactsPopup`, `sendContactsSearchInput`, `sendContactLabelInput`, `sendContactAddressInput`, `sendContactSaveButton`, `sendContactDeleteButton`, `sendContactUseButton`, `sendContinueButton`, `sendOptionsPsbtOperationsButton`, `sendReviewCopyPsbtButton`, `sendReviewSavePsbtButton`, `sendReviewSavePsbtFileDialog`, `sendReviewPsbtStatusText`, `multipleSendReviewCopyPsbtButton`, `multipleSendReviewSavePsbtButton`, `multipleSendReviewSavePsbtFileDialog`, `multipleSendReviewPsbtStatusText`, `sendResultPopup`
25+
- Send flow: `sendAddressInput`, `sendOpenContactsButton`, `sendContactsPopup`, `sendContactsSearchInput`, `sendContactLabelInput`, `sendContactAddressInput`, `sendContactSaveButton`, `sendContactDeleteButton`, `sendContactUseButton`, `sendContinueButton`, `sendPaymentRequestStatusText`, `sendPaymentRequestMessageText`, `sendOptionsPastePaymentRequestButton`, `sendOptionsOpenPaymentRequestButton`, `sendOptionsImportPaymentRequestFileButton`, `sendOptionsPsbtOperationsButton`, `sendUriImportPopup`, `sendUriImportInput`, `sendUriImportApplyButton`, `sendUriImportCancelButton`, `sendImportPaymentRequestFileDialog`, `sendPaymentRequestDropArea`, `sendPaymentRequestDropHint`, `sendDropUriInput`, `sendApplyDropUriButton`, `sendImportPaymentRequestFilePathInput`, `sendApplyPaymentRequestFilePathButton`, `sendReviewCopyPsbtButton`, `sendReviewSavePsbtButton`, `sendReviewSavePsbtFileDialog`, `sendReviewPsbtStatusText`, `multipleSendReviewCopyPsbtButton`, `multipleSendReviewSavePsbtButton`, `multipleSendReviewSavePsbtFileDialog`, `multipleSendReviewPsbtStatusText`, `sendResultPopup`
2626
- PSBT operations flow: `psbtOperationsPage`, `psbtOperationsBackButton`, `psbtImportClipboardButton`, `psbtImportFileButton`, `psbtImportFileDialog`, `psbtWorkflowStatusText`, `psbtInputSummaryText`, `psbtSignButton`, `psbtFinalizeButton`, `psbtBroadcastButton`, `psbtCopyButton`, `psbtSaveButton`, `psbtSaveFileDialog`, `psbtStatusText`, `psbtBroadcastTxidText`
2727
- Wallet tabs/pages: `walletSendPage`, `walletRequestPaymentPage`, `activityListView`, `activityOpenFirstRowActionButton`, `activityOpenRowAddressInput`, `activityOpenRowByAddressActionButton`, `activityDetailsPage`, `activityDetailsBackButton`
2828
- Activity RBF actions: `activityDetailsBumpButton`, `activityDetailsBumpPopup`, `activityDetailsBumpPreviewButton`, `activityDetailsBumpConfirmPopup`, `activityDetailsBumpConfirmButton`, `activityDetailsBumpDisabledReasonText`, `activityDetailsCancelButton`, `activityDetailsCancelPopup`, `activityDetailsCancelConfirmButton`, `activityDetailsRbfStatusText`, `activityDetailsReplacementTxidText`

qml/components/BitcoinAddressInputField.qml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ ColumnLayout {
1717
property string labelText: qsTr("Send to")
1818
property string inputObjectName: ""
1919
property bool enabled: true
20+
property alias inputActiveFocus: addressInput.activeFocus
2021

2122
signal editingFinished()
2223

qml/controls/SendOptionsPopup.qml

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,17 @@ OptionPopup {
1313
id: root
1414
objectName: "sendOptionsPopup"
1515

16+
signal pastePaymentRequestRequested()
17+
signal openPaymentRequestRequested()
18+
signal importPaymentRequestFileRequested()
1619
signal psbtOperationsRequested()
1720

1821
property alias coinControlEnabled: coinControlToggle.checked
1922
property alias multipleRecipientsEnabled: multipleRecipientsToggle.checked
2023
property alias customFeeEnabled: customFeeToggle.checked
2124

2225
implicitWidth: 300
23-
implicitHeight: 190
26+
implicitHeight: 310
2427

2528
clip: true
2629
modal: true
@@ -66,6 +69,48 @@ OptionPopup {
6669
Layout.fillWidth: true
6770
}
6871

72+
EllipsisMenuActionItem {
73+
objectName: "sendOptionsPastePaymentRequestButton"
74+
Layout.fillWidth: true
75+
text: qsTr("Paste payment request")
76+
onClicked: {
77+
root.close()
78+
root.pastePaymentRequestRequested()
79+
}
80+
}
81+
82+
Separator {
83+
Layout.fillWidth: true
84+
}
85+
86+
EllipsisMenuActionItem {
87+
objectName: "sendOptionsOpenPaymentRequestButton"
88+
Layout.fillWidth: true
89+
text: qsTr("Open payment request")
90+
onClicked: {
91+
root.close()
92+
root.openPaymentRequestRequested()
93+
}
94+
}
95+
96+
Separator {
97+
Layout.fillWidth: true
98+
}
99+
100+
EllipsisMenuActionItem {
101+
objectName: "sendOptionsImportPaymentRequestFileButton"
102+
Layout.fillWidth: true
103+
text: qsTr("Import payment request file")
104+
onClicked: {
105+
root.close()
106+
root.importPaymentRequestFileRequested()
107+
}
108+
}
109+
110+
Separator {
111+
Layout.fillWidth: true
112+
}
113+
69114
EllipsisMenuActionItem {
70115
objectName: "sendOptionsPsbtOperationsButton"
71116
Layout.fillWidth: true

qml/models/bip21uri.cpp

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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/bip21uri.h>
6+
7+
#include <key_io.h>
8+
#include <util/moneystr.h>
9+
10+
#include <QObject>
11+
#include <QUrl>
12+
#include <QUrlQuery>
13+
14+
namespace {
15+
Bip21ParseResult BuildError(const QString& message)
16+
{
17+
Bip21ParseResult result;
18+
result.success = false;
19+
result.error = message;
20+
return result;
21+
}
22+
} // namespace
23+
24+
Bip21ParseResult Bip21Uri::Parse(const QString& uri_text)
25+
{
26+
const QString raw = uri_text.trimmed();
27+
if (raw.isEmpty()) {
28+
return BuildError(QObject::tr("Enter a bitcoin: payment URI."));
29+
}
30+
31+
if (raw.startsWith(QStringLiteral("bitcoin://"), Qt::CaseInsensitive)) {
32+
return BuildError(QObject::tr("'bitcoin://' is not a valid URI. Use 'bitcoin:' instead."));
33+
}
34+
35+
const QUrl uri(raw);
36+
if (!uri.isValid() || uri.scheme().compare(QStringLiteral("bitcoin"), Qt::CaseInsensitive) != 0) {
37+
return BuildError(QObject::tr("URI cannot be parsed. Use a valid bitcoin: payment URI."));
38+
}
39+
40+
Bip21ParseResult result;
41+
result.address = uri.path();
42+
if (result.address.endsWith('/')) {
43+
result.address.chop(1);
44+
}
45+
46+
std::string decode_error;
47+
const CTxDestination destination = DecodeDestination(result.address.toStdString(), decode_error);
48+
if (!IsValidDestination(destination)) {
49+
const QString message = decode_error.empty()
50+
? QObject::tr("URI cannot be parsed. Use a valid bitcoin: payment URI.")
51+
: QString::fromStdString(decode_error);
52+
return BuildError(message);
53+
}
54+
55+
const QUrlQuery query(uri);
56+
const auto items = query.queryItems(QUrl::FullyDecoded);
57+
for (const auto& item : items) {
58+
QString key = item.first;
59+
const QString value = item.second;
60+
61+
bool required = false;
62+
if (key.startsWith(QStringLiteral("req-"))) {
63+
required = true;
64+
key.remove(0, 4);
65+
}
66+
67+
bool handled = false;
68+
if (key == QLatin1String("label")) {
69+
result.label = value;
70+
result.has_label = true;
71+
handled = true;
72+
} else if (key == QLatin1String("message")) {
73+
result.message = value;
74+
result.has_message = true;
75+
handled = true;
76+
} else if (key == QLatin1String("amount")) {
77+
if (!value.trimmed().isEmpty()) {
78+
const auto amount_sats = ParseMoney(value.toStdString());
79+
if (!amount_sats.has_value()) {
80+
return BuildError(QObject::tr("URI cannot be parsed. Invalid bitcoin amount."));
81+
}
82+
result.amount_sats = *amount_sats;
83+
result.has_amount = true;
84+
}
85+
handled = true;
86+
}
87+
88+
if (required && !handled) {
89+
return BuildError(QObject::tr("URI cannot be parsed. Unsupported required parameter: %1").arg(key));
90+
}
91+
}
92+
93+
result.success = true;
94+
return result;
95+
}

qml/models/bip21uri.h

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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+
#ifndef BITCOIN_QML_MODELS_BIP21URI_H
6+
#define BITCOIN_QML_MODELS_BIP21URI_H
7+
8+
#include <consensus/amount.h>
9+
10+
#include <QString>
11+
12+
struct Bip21ParseResult
13+
{
14+
bool success{false};
15+
QString error;
16+
QString address;
17+
CAmount amount_sats{0};
18+
bool has_amount{false};
19+
QString label;
20+
bool has_label{false};
21+
QString message;
22+
bool has_message{false};
23+
};
24+
25+
class Bip21Uri
26+
{
27+
public:
28+
static Bip21ParseResult Parse(const QString& uri_text);
29+
};
30+
31+
#endif // BITCOIN_QML_MODELS_BIP21URI_H

qml/models/walletqmlmodel.cpp

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#include <qml/models/walletqmlmodel.h>
77

88
#include <qml/models/activitylistmodel.h>
9+
#include <qml/models/bip21uri.h>
910
#include <qml/models/paymentrequest.h>
1011
#include <qml/models/psbtoperationsadapter.h>
1112
#include <qml/models/sendrecipient.h>
@@ -134,6 +135,21 @@ QVariantMap BuildRbfResultMap(const TransactionRbfActionResult& result)
134135
return payload;
135136
}
136137

138+
QVariantMap BuildBip21ResultMap(const Bip21ParseResult& result)
139+
{
140+
QVariantMap payload;
141+
payload[QStringLiteral("success")] = result.success;
142+
payload[QStringLiteral("message")] = result.success ? QObject::tr("Payment request loaded.") : result.error;
143+
payload[QStringLiteral("address")] = result.address;
144+
payload[QStringLiteral("amountSats")] = static_cast<qint64>(result.amount_sats);
145+
payload[QStringLiteral("hasAmount")] = result.has_amount;
146+
payload[QStringLiteral("label")] = result.label;
147+
payload[QStringLiteral("hasLabel")] = result.has_label;
148+
payload[QStringLiteral("uriMessage")] = result.message;
149+
payload[QStringLiteral("hasMessage")] = result.has_message;
150+
return payload;
151+
}
152+
137153
QString ResolveLocalDestinationPath(const QString& destination)
138154
{
139155
const QString trimmed = destination.trimmed();
@@ -880,6 +896,51 @@ QVariantMap WalletQmlModel::savePsbtToFile(const QString& psbt_base64, const QSt
880896
return BuildPsbtPayload(true, tr("PSBT saved to disk."), psbt_base64);
881897
}
882898

899+
QVariantMap WalletQmlModel::parseBitcoinUri(const QString& uri_text) const
900+
{
901+
return BuildBip21ResultMap(Bip21Uri::Parse(uri_text));
902+
}
903+
904+
QVariantMap WalletQmlModel::parseBitcoinUriFromFile(const QString& source_path) const
905+
{
906+
const QString path = ResolveLocalSourcePath(source_path);
907+
if (path.isEmpty()) {
908+
QVariantMap payload;
909+
payload[QStringLiteral("success")] = false;
910+
payload[QStringLiteral("message")] = tr("Choose a payment request file to import.");
911+
return payload;
912+
}
913+
914+
QFile file(path);
915+
if (!file.open(QIODevice::ReadOnly)) {
916+
QVariantMap payload;
917+
payload[QStringLiteral("success")] = false;
918+
payload[QStringLiteral("message")] = tr("Unable to open payment request file: %1").arg(file.errorString());
919+
return payload;
920+
}
921+
922+
constexpr qint64 MAX_URI_FILE_BYTES = 1024 * 1024;
923+
const qint64 file_size = file.size();
924+
if (file_size > MAX_URI_FILE_BYTES) {
925+
QVariantMap payload;
926+
payload[QStringLiteral("success")] = false;
927+
payload[QStringLiteral("message")] = tr("Payment request file must be smaller than 1 MiB.");
928+
return payload;
929+
}
930+
931+
const QString file_text = QString::fromUtf8(file.readAll()).trimmed();
932+
file.close();
933+
934+
if (file_text.isEmpty()) {
935+
QVariantMap payload;
936+
payload[QStringLiteral("success")] = false;
937+
payload[QStringLiteral("message")] = tr("Payment request file is empty.");
938+
return payload;
939+
}
940+
941+
return BuildBip21ResultMap(Bip21Uri::Parse(file_text));
942+
}
943+
883944
void WalletQmlModel::sendTransaction()
884945
{
885946
if (!m_wallet || !m_current_transaction) {

qml/models/walletqmlmodel.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ class WalletQmlModel : public QObject, public WalletBalanceProvider
7373
Q_INVOKABLE QVariantMap signPsbt(const QString& psbt_base64);
7474
Q_INVOKABLE QVariantMap finalizePsbt(const QString& psbt_base64);
7575
Q_INVOKABLE QVariantMap savePsbtToFile(const QString& psbt_base64, const QString& destination);
76+
Q_INVOKABLE QVariantMap parseBitcoinUri(const QString& uri_text) const;
77+
Q_INVOKABLE QVariantMap parseBitcoinUriFromFile(const QString& source_path) const;
7678
Q_INVOKABLE void sendTransaction();
7779
Q_INVOKABLE QString newAddress(QString label);
7880
bool isEncrypted() const;

0 commit comments

Comments
 (0)