Skip to content

Commit b8bd645

Browse files
committed
qml: add send-review PSBT save-to-file flow (issue 18)
1 parent 8c01f08 commit b8bd645

11 files changed

Lines changed: 253 additions & 5 deletions

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`, `sendReviewCopyPsbtButton`, `sendReviewPsbtStatusText`, `multipleSendReviewCopyPsbtButton`, `multipleSendReviewPsbtStatusText`, `sendResultPopup`
25+
- Send flow: `sendAddressInput`, `sendOpenContactsButton`, `sendContactsPopup`, `sendContactsSearchInput`, `sendContactLabelInput`, `sendContactAddressInput`, `sendContactSaveButton`, `sendContactDeleteButton`, `sendContactUseButton`, `sendContinueButton`, `sendReviewCopyPsbtButton`, `sendReviewSavePsbtButton`, `sendReviewSavePsbtFileDialog`, `sendReviewPsbtStatusText`, `multipleSendReviewCopyPsbtButton`, `multipleSendReviewSavePsbtButton`, `multipleSendReviewSavePsbtFileDialog`, `multipleSendReviewPsbtStatusText`, `sendResultPopup`
2626
- Wallet tabs/pages: `walletSendPage`, `walletRequestPaymentPage`, `activityListView`, `activityOpenFirstRowActionButton`, `activityOpenRowAddressInput`, `activityOpenRowByAddressActionButton`, `activityDetailsPage`, `activityDetailsBackButton`
2727
- Activity RBF actions: `activityDetailsBumpButton`, `activityDetailsBumpPopup`, `activityDetailsBumpPreviewButton`, `activityDetailsBumpConfirmPopup`, `activityDetailsBumpConfirmButton`, `activityDetailsBumpDisabledReasonText`, `activityDetailsCancelButton`, `activityDetailsCancelPopup`, `activityDetailsCancelConfirmButton`, `activityDetailsRbfStatusText`, `activityDetailsReplacementTxidText`
2828
- Onboarding storage flow: `onboardingStorageAmountDetailedSettingsButton`, `storageSettingsPruneTargetInput`, `storageLocationDefaultOption`

qml/models/psbtoperationsadapter.cpp

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
#include <util/strencodings.h>
1111
#include <util/translation.h>
1212

13+
#include <QFile>
1314
#include <QObject>
1415

1516
#include <string>
@@ -147,3 +148,35 @@ QByteArray PsbtOperationsAdapter::EncodeRaw(const PartiallySignedTransaction& ps
147148
const std::string raw = stream.str();
148149
return QByteArray(raw.data(), static_cast<int>(raw.size()));
149150
}
151+
152+
bool PsbtOperationsAdapter::WriteRawToFile(const PartiallySignedTransaction& psbt,
153+
const QString& file_path,
154+
QString* error)
155+
{
156+
if (file_path.trimmed().isEmpty()) {
157+
if (error) {
158+
*error = QObject::tr("Choose a destination file path.");
159+
}
160+
return false;
161+
}
162+
163+
QFile file(file_path);
164+
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
165+
if (error) {
166+
*error = QObject::tr("Unable to save PSBT to disk: %1").arg(file.errorString());
167+
}
168+
return false;
169+
}
170+
171+
const QByteArray raw = EncodeRaw(psbt);
172+
const qint64 written = file.write(raw);
173+
file.close();
174+
if (written != raw.size()) {
175+
if (error) {
176+
*error = QObject::tr("Unable to save PSBT to disk: %1").arg(file.errorString());
177+
}
178+
return false;
179+
}
180+
181+
return true;
182+
}

qml/models/psbtoperationsadapter.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ class PsbtOperationsAdapter
4646

4747
static QString EncodeBase64(const PartiallySignedTransaction& psbt);
4848
static QByteArray EncodeRaw(const PartiallySignedTransaction& psbt);
49+
static bool WriteRawToFile(const PartiallySignedTransaction& psbt,
50+
const QString& file_path,
51+
QString* error = nullptr);
4952
};
5053

5154
#endif // BITCOIN_QML_MODELS_PSBTOPERATIONSADAPTER_H

qml/models/walletqmlmodel.cpp

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
#include <QDateTime>
3030
#include <QMetaObject>
31+
#include <QUrl>
3132
#include <QVector>
3233

3334
#include <limits>
@@ -128,6 +129,25 @@ QVariantMap BuildRbfResultMap(const TransactionRbfActionResult& result)
128129
payload[QStringLiteral("replacementTxid")] = result.replacement_txid;
129130
return payload;
130131
}
132+
133+
QString ResolveLocalDestinationPath(const QString& destination)
134+
{
135+
const QString trimmed = destination.trimmed();
136+
if (trimmed.isEmpty()) {
137+
return {};
138+
}
139+
140+
const QUrl url(trimmed);
141+
if (url.isValid() && url.isLocalFile()) {
142+
return url.toLocalFile();
143+
}
144+
145+
if (url.scheme().isEmpty()) {
146+
return trimmed;
147+
}
148+
149+
return {};
150+
}
131151
} // namespace
132152

133153
WalletQmlModel::WalletQmlModel(std::unique_ptr<interfaces::Wallet> wallet, QObject *parent)
@@ -555,6 +575,48 @@ QVariantMap WalletQmlModel::createUnsignedPsbt()
555575
return payload;
556576
}
557577

578+
QVariantMap WalletQmlModel::saveUnsignedPsbt(const QString& destination)
579+
{
580+
QVariantMap payload;
581+
if (!m_wallet) {
582+
payload[QStringLiteral("success")] = false;
583+
payload[QStringLiteral("message")] = tr("No wallet is loaded.");
584+
return payload;
585+
}
586+
587+
if (!m_current_transaction || !m_current_transaction->getWtx()) {
588+
payload[QStringLiteral("success")] = false;
589+
payload[QStringLiteral("message")] = tr("No transaction is available to export.");
590+
return payload;
591+
}
592+
593+
const QString path = ResolveLocalDestinationPath(destination);
594+
if (path.isEmpty()) {
595+
payload[QStringLiteral("success")] = false;
596+
payload[QStringLiteral("message")] = tr("Choose a destination file path.");
597+
return payload;
598+
}
599+
600+
WalletPsbtFillBackend backend(*m_wallet);
601+
const PsbtCreateResult result = PsbtOperationsAdapter::CreateUnsignedPsbt(backend, m_current_transaction->getWtx());
602+
if (!result.success || !result.psbt.has_value()) {
603+
payload[QStringLiteral("success")] = false;
604+
payload[QStringLiteral("message")] = result.message;
605+
return payload;
606+
}
607+
608+
QString write_error;
609+
if (!PsbtOperationsAdapter::WriteRawToFile(*result.psbt, path, &write_error)) {
610+
payload[QStringLiteral("success")] = false;
611+
payload[QStringLiteral("message")] = write_error;
612+
return payload;
613+
}
614+
615+
payload[QStringLiteral("success")] = true;
616+
payload[QStringLiteral("message")] = tr("PSBT saved to disk.");
617+
return payload;
618+
}
619+
558620
void WalletQmlModel::sendTransaction()
559621
{
560622
if (!m_wallet || !m_current_transaction) {

qml/models/walletqmlmodel.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ class WalletQmlModel : public QObject, public WalletBalanceProvider
6767
WalletQmlModelTransaction* currentTransaction() const { return m_current_transaction; }
6868
Q_INVOKABLE bool prepareTransaction();
6969
Q_INVOKABLE QVariantMap createUnsignedPsbt();
70+
Q_INVOKABLE QVariantMap saveUnsignedPsbt(const QString& destination);
7071
Q_INVOKABLE void sendTransaction();
7172
Q_INVOKABLE QString newAddress(QString label);
7273
bool isEncrypted() const;

qml/pages/wallet/MultipleSendReview.qml

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import QtQuick 2.15
66
import QtQuick.Controls 2.15
77
import QtQuick.Layouts 1.15
8+
import QtQuick.Dialogs
89
import org.bitcoincore.qt 1.0
910

1011
import "../../controls"
@@ -24,6 +25,18 @@ Page {
2425
signal back()
2526
signal transactionSent()
2627

28+
function applyPsbtResult(result) {
29+
root.psbtStatusSuccess = !!result.success
30+
root.psbtStatusMessage = result.message || ""
31+
}
32+
33+
function savePsbtToDestination(destination) {
34+
if (!root.wallet || !root.wallet.saveUnsignedPsbt) {
35+
return
36+
}
37+
applyPsbtResult(root.wallet.saveUnsignedPsbt(destination))
38+
}
39+
2740
header: NavigationBar2 {
2841
id: navbar
2942
leftItem: NavButton {
@@ -161,14 +174,20 @@ Page {
161174
}
162175

163176
const result = root.wallet.createUnsignedPsbt()
164-
root.psbtStatusSuccess = !!result.success
165-
root.psbtStatusMessage = result.message || ""
177+
applyPsbtResult(result)
166178
if (result.success && result.psbtBase64) {
167179
Clipboard.setText(result.psbtBase64)
168180
}
169181
}
170182
}
171183

184+
OutlineButton {
185+
objectName: "multipleSendReviewSavePsbtButton"
186+
Layout.fillWidth: true
187+
text: qsTr("Save PSBT")
188+
onClicked: multipleSendReviewSavePsbtFileDialog.open()
189+
}
190+
172191
CoreText {
173192
objectName: "multipleSendReviewPsbtStatusText"
174193
Layout.fillWidth: true
@@ -179,6 +198,14 @@ Page {
179198
wrapMode: Text.WordWrap
180199
}
181200

201+
FileDialog {
202+
id: multipleSendReviewSavePsbtFileDialog
203+
objectName: "multipleSendReviewSavePsbtFileDialog"
204+
title: qsTr("Save Transaction Data")
205+
nameFilters: [qsTr("Partially Signed Transaction (Binary) (*.psbt)"), qsTr("All files (*)")]
206+
onAccepted: savePsbtToDestination(multipleSendReviewSavePsbtFileDialog.fileUrl.toString())
207+
}
208+
182209
ContinueButton {
183210
id: confirmationButton
184211
objectName: "multipleSendConfirmButton"

qml/pages/wallet/SendReview.qml

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import QtQuick 2.15
66
import QtQuick.Controls 2.15
77
import QtQuick.Layouts 1.15
8+
import QtQuick.Dialogs
89
import org.bitcoincore.qt 1.0
910

1011
import "../../controls"
@@ -24,6 +25,18 @@ Page {
2425
signal back()
2526
signal transactionSent()
2627

28+
function applyPsbtResult(result) {
29+
root.psbtStatusSuccess = !!result.success
30+
root.psbtStatusMessage = result.message || ""
31+
}
32+
33+
function savePsbtToDestination(destination) {
34+
if (!root.wallet || !root.wallet.saveUnsignedPsbt) {
35+
return
36+
}
37+
applyPsbtResult(root.wallet.saveUnsignedPsbt(destination))
38+
}
39+
2740
header: NavigationBar2 {
2841
id: navbar
2942
leftItem: NavButton {
@@ -149,14 +162,20 @@ Page {
149162
}
150163

151164
const result = root.wallet.createUnsignedPsbt()
152-
root.psbtStatusSuccess = !!result.success
153-
root.psbtStatusMessage = result.message || ""
165+
applyPsbtResult(result)
154166
if (result.success && result.psbtBase64) {
155167
Clipboard.setText(result.psbtBase64)
156168
}
157169
}
158170
}
159171

172+
OutlineButton {
173+
objectName: "sendReviewSavePsbtButton"
174+
Layout.fillWidth: true
175+
text: qsTr("Save PSBT")
176+
onClicked: sendReviewSavePsbtFileDialog.open()
177+
}
178+
160179
CoreText {
161180
objectName: "sendReviewPsbtStatusText"
162181
Layout.fillWidth: true
@@ -167,6 +186,14 @@ Page {
167186
wrapMode: Text.WordWrap
168187
}
169188

189+
FileDialog {
190+
id: sendReviewSavePsbtFileDialog
191+
objectName: "sendReviewSavePsbtFileDialog"
192+
title: qsTr("Save Transaction Data")
193+
nameFilters: [qsTr("Partially Signed Transaction (Binary) (*.psbt)"), qsTr("All files (*)")]
194+
onAccepted: savePsbtToDestination(sendReviewSavePsbtFileDialog.fileUrl.toString())
195+
}
196+
170197
ContinueButton {
171198
id: confimationButton
172199
objectName: "sendConfirmButton"

test/qml/qml_tests_main.cpp

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -976,6 +976,7 @@ class MockWalletQmlModel : public QObject
976976
Q_PROPERTY(int prepareTransactionCalls READ prepareTransactionCalls NOTIFY prepareTransactionCallsChanged)
977977
Q_PROPERTY(int sendTransactionCalls READ sendTransactionCalls NOTIFY sendTransactionCallsChanged)
978978
Q_PROPERTY(int createUnsignedPsbtCalls READ createUnsignedPsbtCalls NOTIFY createUnsignedPsbtCallsChanged)
979+
Q_PROPERTY(int saveUnsignedPsbtCalls READ saveUnsignedPsbtCalls NOTIFY saveUnsignedPsbtCallsChanged)
979980

980981
public:
981982
QString m_name{QStringLiteral("testwallet")};
@@ -995,6 +996,9 @@ class MockWalletQmlModel : public QObject
995996
bool m_create_unsigned_psbt_success{true};
996997
QString m_create_unsigned_psbt_message{QStringLiteral("PSBT copied to clipboard.")};
997998
QString m_create_unsigned_psbt_base64{QStringLiteral("cHNidP8BAHECAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AaCGAQAAAAAAIgAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAR8AAAAAAAEfAAAAAA==")};
999+
bool m_save_unsigned_psbt_success{true};
1000+
QString m_save_unsigned_psbt_message{QStringLiteral("PSBT saved to disk.")};
1001+
QString m_last_save_unsigned_psbt_path;
9981002
const QString m_default_rbf_eligible_txid{QStringLiteral("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")};
9991003
QString m_rbf_eligible_txid{m_default_rbf_eligible_txid};
10001004
QString m_prepared_rbf_txid;
@@ -1009,6 +1013,7 @@ class MockWalletQmlModel : public QObject
10091013
int prepareTransactionCalls() const { return m_prepare_transaction_calls; }
10101014
int sendTransactionCalls() const { return m_send_transaction_calls; }
10111015
int createUnsignedPsbtCalls() const { return m_create_unsigned_psbt_calls; }
1016+
int saveUnsignedPsbtCalls() const { return m_save_unsigned_psbt_calls; }
10121017
void setActivityListModel(QObject* model) { m_activity_list_model = model; }
10131018
void setAddressBookModel(QObject* model) { m_address_book_model = model; }
10141019
void setRecipients(QObject* model) { m_recipients = model; }
@@ -1049,6 +1054,29 @@ class MockWalletQmlModel : public QObject
10491054
m_create_unsigned_psbt_base64 = base64;
10501055
}
10511056

1057+
Q_INVOKABLE QVariantMap saveUnsignedPsbt(const QString& destination)
1058+
{
1059+
++m_save_unsigned_psbt_calls;
1060+
m_last_save_unsigned_psbt_path = destination;
1061+
Q_EMIT saveUnsignedPsbtCallsChanged();
1062+
1063+
QVariantMap result;
1064+
result.insert(QStringLiteral("success"), m_save_unsigned_psbt_success);
1065+
result.insert(QStringLiteral("message"), m_save_unsigned_psbt_message);
1066+
return result;
1067+
}
1068+
1069+
Q_INVOKABLE void setSaveUnsignedPsbtResult(const bool success, const QString& message)
1070+
{
1071+
m_save_unsigned_psbt_success = success;
1072+
m_save_unsigned_psbt_message = message;
1073+
}
1074+
1075+
Q_INVOKABLE QString lastSaveUnsignedPsbtPath() const
1076+
{
1077+
return m_last_save_unsigned_psbt_path;
1078+
}
1079+
10521080
Q_INVOKABLE QString getAddressLabel(const QString& address) const
10531081
{
10541082
auto* address_book = qobject_cast<MockAddressBookModel*>(m_address_book_model);
@@ -1208,11 +1236,13 @@ class MockWalletQmlModel : public QObject
12081236
void prepareTransactionCallsChanged();
12091237
void sendTransactionCallsChanged();
12101238
void createUnsignedPsbtCallsChanged();
1239+
void saveUnsignedPsbtCallsChanged();
12111240

12121241
private:
12131242
int m_prepare_transaction_calls{0};
12141243
int m_send_transaction_calls{0};
12151244
int m_create_unsigned_psbt_calls{0};
1245+
int m_save_unsigned_psbt_calls{0};
12161246
};
12171247

12181248
class MockWalletQmlModelTransaction : public QObject

test/qml/tst_multiplesendreview.qml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ TestCase {
3535
verify(findChild(page, "multipleSendReviewBackButton") !== null)
3636
verify(findChild(page, "multipleSendInputsList") !== null)
3737
verify(findChild(page, "multipleSendReviewCopyPsbtButton") !== null)
38+
verify(findChild(page, "multipleSendReviewSavePsbtButton") !== null)
39+
verify(findChild(page, "multipleSendReviewSavePsbtFileDialog") !== null)
3840
verify(findChild(page, "multipleSendReviewPsbtStatusText") !== null)
3941
verify(findChild(page, "multipleSendConfirmButton") !== null)
4042
}
@@ -77,6 +79,23 @@ TestCase {
7779
compare(Clipboard.text, "cHNidP8BAHECAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AaCGAQAAAAAAIgAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAR8AAAAAAAEfAAAAAA==")
7880
}
7981

82+
function test_multipleSendReview_save_psbt_calls_wallet_and_shows_status() {
83+
testWalletModel.setSaveUnsignedPsbtResult(true, "PSBT saved to disk.")
84+
85+
const page = createTemporaryObject(multipleSendReviewComponent, this)
86+
verify(page !== null)
87+
88+
const statusText = findChild(page, "multipleSendReviewPsbtStatusText")
89+
verify(statusText !== null)
90+
91+
const callsBefore = testWalletModel.saveUnsignedPsbtCalls
92+
page.savePsbtToDestination("file:///tmp/multiple-send-review.psbt")
93+
94+
compare(testWalletModel.saveUnsignedPsbtCalls, callsBefore + 1)
95+
compare(testWalletModel.lastSaveUnsignedPsbtPath(), "file:///tmp/multiple-send-review.psbt")
96+
compare(statusText.text, "PSBT saved to disk.")
97+
}
98+
8099
function test_multipleSendReview_back_and_confirm_signals() {
81100
const page = createTemporaryObject(multipleSendReviewComponent, this)
82101
verify(page !== null)

0 commit comments

Comments
 (0)