Skip to content

Commit 8c01f08

Browse files
committed
Issue 18: add send-review PSBT copy export flow
1 parent de11d09 commit 8c01f08

11 files changed

Lines changed: 293 additions & 1 deletion

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`, `sendResultPopup`
25+
- Send flow: `sendAddressInput`, `sendOpenContactsButton`, `sendContactsPopup`, `sendContactsSearchInput`, `sendContactLabelInput`, `sendContactAddressInput`, `sendContactSaveButton`, `sendContactDeleteButton`, `sendContactUseButton`, `sendContinueButton`, `sendReviewCopyPsbtButton`, `sendReviewPsbtStatusText`, `multipleSendReviewCopyPsbtButton`, `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: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
#include <qml/models/psbtoperationsadapter.h>
66

7+
#include <common/messages.h>
78
#include <psbt.h>
89
#include <streams.h>
910
#include <util/strencodings.h>
11+
#include <util/translation.h>
1012

1113
#include <QObject>
1214

@@ -30,6 +32,12 @@ PsbtDecodeResult DecodeRawPayload(const std::vector<unsigned char>& payload)
3032
result.psbt = std::move(psbt);
3133
return result;
3234
}
35+
36+
QString BuildFillPsbtError(const common::PSBTError error)
37+
{
38+
return QObject::tr("Failed to create unsigned PSBT: %1")
39+
.arg(QString::fromStdString(common::PSBTErrorString(error).translated));
40+
}
3341
} // namespace
3442

3543
PsbtDecodeResult PsbtOperationsAdapter::DecodeClipboardBase64(const QString& base64_text)
@@ -94,6 +102,36 @@ PsbtDecodeResult PsbtOperationsAdapter::DecodeFilePayload(const QByteArray& payl
94102
return DecodeRawPayload(decode_payload);
95103
}
96104

105+
PsbtCreateResult PsbtOperationsAdapter::CreateUnsignedPsbt(PsbtFillBackend& backend, const CTransactionRef& tx)
106+
{
107+
if (!tx) {
108+
return {
109+
false,
110+
QObject::tr("No transaction is available to export."),
111+
QString{},
112+
std::nullopt,
113+
};
114+
}
115+
116+
PartiallySignedTransaction psbt{CMutableTransaction{*tx}};
117+
bool complete{false};
118+
const auto maybe_error = backend.fillUnsigned(psbt, complete);
119+
if (maybe_error.has_value()) {
120+
return {
121+
false,
122+
BuildFillPsbtError(*maybe_error),
123+
QString{},
124+
std::nullopt,
125+
};
126+
}
127+
128+
PsbtCreateResult result;
129+
result.success = true;
130+
result.psbt = psbt;
131+
result.base64 = EncodeBase64(psbt);
132+
return result;
133+
}
134+
97135
QString PsbtOperationsAdapter::EncodeBase64(const PartiallySignedTransaction& psbt)
98136
{
99137
DataStream stream{};

qml/models/psbtoperationsadapter.h

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
#ifndef BITCOIN_QML_MODELS_PSBTOPERATIONSADAPTER_H
66
#define BITCOIN_QML_MODELS_PSBTOPERATIONSADAPTER_H
77

8+
#include <common/types.h>
9+
#include <primitives/transaction.h>
810
#include <psbt.h>
911

1012
#include <optional>
@@ -19,13 +21,28 @@ struct PsbtDecodeResult {
1921
std::optional<PartiallySignedTransaction> psbt;
2022
};
2123

24+
struct PsbtCreateResult {
25+
bool success{false};
26+
QString message;
27+
QString base64;
28+
std::optional<PartiallySignedTransaction> psbt;
29+
};
30+
31+
class PsbtFillBackend
32+
{
33+
public:
34+
virtual ~PsbtFillBackend() = default;
35+
virtual std::optional<common::PSBTError> fillUnsigned(PartiallySignedTransaction& psbt, bool& complete) = 0;
36+
};
37+
2238
class PsbtOperationsAdapter
2339
{
2440
public:
2541
static constexpr qint64 MAX_PSBT_FILE_SIZE_BYTES{100 * 1024 * 1024};
2642

2743
static PsbtDecodeResult DecodeClipboardBase64(const QString& base64_text);
2844
static PsbtDecodeResult DecodeFilePayload(const QByteArray& payload, qint64 file_size_bytes = -1);
45+
static PsbtCreateResult CreateUnsignedPsbt(PsbtFillBackend& backend, const CTransactionRef& tx);
2946

3047
static QString EncodeBase64(const PartiallySignedTransaction& psbt);
3148
static QByteArray EncodeRaw(const PartiallySignedTransaction& psbt);

qml/models/walletqmlmodel.cpp

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
#include <qml/models/activitylistmodel.h>
99
#include <qml/models/paymentrequest.h>
10+
#include <qml/models/psbtoperationsadapter.h>
1011
#include <qml/models/sendrecipient.h>
1112
#include <qml/models/sendrecipientslistmodel.h>
1213
#include <qml/models/transactionrbfadapter.h>
@@ -97,6 +98,28 @@ class WalletTransactionRbfBackend final : public TransactionRbfBackend
9798
interfaces::Wallet& m_wallet;
9899
};
99100

101+
class WalletPsbtFillBackend final : public PsbtFillBackend
102+
{
103+
public:
104+
explicit WalletPsbtFillBackend(interfaces::Wallet& wallet)
105+
: m_wallet(wallet)
106+
{
107+
}
108+
109+
std::optional<common::PSBTError> fillUnsigned(PartiallySignedTransaction& psbt, bool& complete) override
110+
{
111+
return m_wallet.fillPSBT(std::nullopt,
112+
/*sign=*/false,
113+
/*bip32derivs=*/true,
114+
/*n_signed=*/nullptr,
115+
psbt,
116+
complete);
117+
}
118+
119+
private:
120+
interfaces::Wallet& m_wallet;
121+
};
122+
100123
QVariantMap BuildRbfResultMap(const TransactionRbfActionResult& result)
101124
{
102125
QVariantMap payload;
@@ -506,6 +529,32 @@ bool WalletQmlModel::prepareTransaction()
506529
return true;
507530
}
508531

532+
QVariantMap WalletQmlModel::createUnsignedPsbt()
533+
{
534+
QVariantMap payload;
535+
if (!m_wallet) {
536+
payload[QStringLiteral("success")] = false;
537+
payload[QStringLiteral("message")] = tr("No wallet is loaded.");
538+
return payload;
539+
}
540+
541+
if (!m_current_transaction || !m_current_transaction->getWtx()) {
542+
payload[QStringLiteral("success")] = false;
543+
payload[QStringLiteral("message")] = tr("No transaction is available to export.");
544+
return payload;
545+
}
546+
547+
WalletPsbtFillBackend backend(*m_wallet);
548+
const PsbtCreateResult result = PsbtOperationsAdapter::CreateUnsignedPsbt(backend, m_current_transaction->getWtx());
549+
550+
payload[QStringLiteral("success")] = result.success;
551+
payload[QStringLiteral("message")] = result.success
552+
? tr("PSBT copied to clipboard.")
553+
: result.message;
554+
payload[QStringLiteral("psbtBase64")] = result.base64;
555+
return payload;
556+
}
557+
509558
void WalletQmlModel::sendTransaction()
510559
{
511560
if (!m_wallet || !m_current_transaction) {

qml/models/walletqmlmodel.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ class WalletQmlModel : public QObject, public WalletBalanceProvider
6666
ReceiveRequestHistoryModel* receiveRequests() const { return m_receive_requests; }
6767
WalletQmlModelTransaction* currentTransaction() const { return m_current_transaction; }
6868
Q_INVOKABLE bool prepareTransaction();
69+
Q_INVOKABLE QVariantMap createUnsignedPsbt();
6970
Q_INVOKABLE void sendTransaction();
7071
Q_INVOKABLE QString newAddress(QString label);
7172
bool isEncrypted() const;

qml/pages/wallet/MultipleSendReview.qml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ Page {
1717

1818
property WalletQmlModel wallet: walletController.selectedWallet
1919
property WalletQmlModelTransaction transaction: walletController.selectedWallet.currentTransaction
20+
property string psbtStatusMessage: ""
21+
property bool psbtStatusSuccess: true
2022

2123
signal finished()
2224
signal back()
@@ -149,6 +151,34 @@ Page {
149151
color: Theme.color.neutral3
150152
}
151153

154+
OutlineButton {
155+
objectName: "multipleSendReviewCopyPsbtButton"
156+
Layout.fillWidth: true
157+
text: qsTr("Copy PSBT")
158+
onClicked: {
159+
if (!root.wallet || !root.wallet.createUnsignedPsbt) {
160+
return
161+
}
162+
163+
const result = root.wallet.createUnsignedPsbt()
164+
root.psbtStatusSuccess = !!result.success
165+
root.psbtStatusMessage = result.message || ""
166+
if (result.success && result.psbtBase64) {
167+
Clipboard.setText(result.psbtBase64)
168+
}
169+
}
170+
}
171+
172+
CoreText {
173+
objectName: "multipleSendReviewPsbtStatusText"
174+
Layout.fillWidth: true
175+
visible: root.psbtStatusMessage.length > 0
176+
text: root.psbtStatusMessage
177+
color: root.psbtStatusSuccess ? Theme.color.green : Theme.color.red
178+
font.pixelSize: 14
179+
wrapMode: Text.WordWrap
180+
}
181+
152182
ContinueButton {
153183
id: confirmationButton
154184
objectName: "multipleSendConfirmButton"

qml/pages/wallet/SendReview.qml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ Page {
1717

1818
property WalletQmlModel wallet: walletController.selectedWallet
1919
property WalletQmlModelTransaction transaction: walletController.selectedWallet.currentTransaction
20+
property string psbtStatusMessage: ""
21+
property bool psbtStatusSuccess: true
2022

2123
signal finished()
2224
signal back()
@@ -137,6 +139,34 @@ Page {
137139
}
138140
}
139141

142+
OutlineButton {
143+
objectName: "sendReviewCopyPsbtButton"
144+
Layout.fillWidth: true
145+
text: qsTr("Copy PSBT")
146+
onClicked: {
147+
if (!root.wallet || !root.wallet.createUnsignedPsbt) {
148+
return
149+
}
150+
151+
const result = root.wallet.createUnsignedPsbt()
152+
root.psbtStatusSuccess = !!result.success
153+
root.psbtStatusMessage = result.message || ""
154+
if (result.success && result.psbtBase64) {
155+
Clipboard.setText(result.psbtBase64)
156+
}
157+
}
158+
}
159+
160+
CoreText {
161+
objectName: "sendReviewPsbtStatusText"
162+
Layout.fillWidth: true
163+
visible: root.psbtStatusMessage.length > 0
164+
text: root.psbtStatusMessage
165+
color: root.psbtStatusSuccess ? Theme.color.green : Theme.color.red
166+
font.pixelSize: 14
167+
wrapMode: Text.WordWrap
168+
}
169+
140170
ContinueButton {
141171
id: confimationButton
142172
objectName: "sendConfirmButton"

test/qml/qml_tests_main.cpp

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,7 @@ class MockWalletQmlModel : public QObject
975975
Q_PROPERTY(bool prepareTransactionResult MEMBER m_prepare_transaction_result NOTIFY prepareTransactionResultChanged)
976976
Q_PROPERTY(int prepareTransactionCalls READ prepareTransactionCalls NOTIFY prepareTransactionCallsChanged)
977977
Q_PROPERTY(int sendTransactionCalls READ sendTransactionCalls NOTIFY sendTransactionCallsChanged)
978+
Q_PROPERTY(int createUnsignedPsbtCalls READ createUnsignedPsbtCalls NOTIFY createUnsignedPsbtCallsChanged)
978979

979980
public:
980981
QString m_name{QStringLiteral("testwallet")};
@@ -991,6 +992,9 @@ class MockWalletQmlModel : public QObject
991992
qint64 m_custom_fee_rate_sat_per_kvb{1000};
992993
QString m_last_prepare_error;
993994
bool m_prepare_transaction_result{true};
995+
bool m_create_unsigned_psbt_success{true};
996+
QString m_create_unsigned_psbt_message{QStringLiteral("PSBT copied to clipboard.")};
997+
QString m_create_unsigned_psbt_base64{QStringLiteral("cHNidP8BAHECAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AaCGAQAAAAAAIgAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAR8AAAAAAAEfAAAAAA==")};
994998
const QString m_default_rbf_eligible_txid{QStringLiteral("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")};
995999
QString m_rbf_eligible_txid{m_default_rbf_eligible_txid};
9961000
QString m_prepared_rbf_txid;
@@ -1004,6 +1008,7 @@ class MockWalletQmlModel : public QObject
10041008
QObject* receiveRequests() const { return m_receive_requests; }
10051009
int prepareTransactionCalls() const { return m_prepare_transaction_calls; }
10061010
int sendTransactionCalls() const { return m_send_transaction_calls; }
1011+
int createUnsignedPsbtCalls() const { return m_create_unsigned_psbt_calls; }
10071012
void setActivityListModel(QObject* model) { m_activity_list_model = model; }
10081013
void setAddressBookModel(QObject* model) { m_address_book_model = model; }
10091014
void setRecipients(QObject* model) { m_recipients = model; }
@@ -1025,6 +1030,25 @@ class MockWalletQmlModel : public QObject
10251030
Q_EMIT sendTransactionCallsChanged();
10261031
}
10271032

1033+
Q_INVOKABLE QVariantMap createUnsignedPsbt()
1034+
{
1035+
++m_create_unsigned_psbt_calls;
1036+
Q_EMIT createUnsignedPsbtCallsChanged();
1037+
1038+
QVariantMap result;
1039+
result.insert(QStringLiteral("success"), m_create_unsigned_psbt_success);
1040+
result.insert(QStringLiteral("message"), m_create_unsigned_psbt_message);
1041+
result.insert(QStringLiteral("psbtBase64"), m_create_unsigned_psbt_success ? m_create_unsigned_psbt_base64 : QString{});
1042+
return result;
1043+
}
1044+
1045+
Q_INVOKABLE void setCreateUnsignedPsbtResult(const bool success, const QString& message, const QString& base64 = QString{})
1046+
{
1047+
m_create_unsigned_psbt_success = success;
1048+
m_create_unsigned_psbt_message = message;
1049+
m_create_unsigned_psbt_base64 = base64;
1050+
}
1051+
10281052
Q_INVOKABLE QString getAddressLabel(const QString& address) const
10291053
{
10301054
auto* address_book = qobject_cast<MockAddressBookModel*>(m_address_book_model);
@@ -1183,10 +1207,12 @@ class MockWalletQmlModel : public QObject
11831207
void prepareTransactionResultChanged();
11841208
void prepareTransactionCallsChanged();
11851209
void sendTransactionCallsChanged();
1210+
void createUnsignedPsbtCallsChanged();
11861211

11871212
private:
11881213
int m_prepare_transaction_calls{0};
11891214
int m_send_transaction_calls{0};
1215+
int m_create_unsigned_psbt_calls{0};
11901216
};
11911217

11921218
class MockWalletQmlModelTransaction : public QObject

test/qml/tst_multiplesendreview.qml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import QtQuick 2.15
66
import QtTest 1.2
7+
import org.bitcoincore.qt 1.0
78
import "../../qml/pages/wallet"
89

910
TestCase {
@@ -33,6 +34,8 @@ TestCase {
3334
compare(page.objectName, "walletMultipleSendReviewPage")
3435
verify(findChild(page, "multipleSendReviewBackButton") !== null)
3536
verify(findChild(page, "multipleSendInputsList") !== null)
37+
verify(findChild(page, "multipleSendReviewCopyPsbtButton") !== null)
38+
verify(findChild(page, "multipleSendReviewPsbtStatusText") !== null)
3639
verify(findChild(page, "multipleSendConfirmButton") !== null)
3740
}
3841

@@ -52,6 +55,28 @@ TestCase {
5255
tryCompare(list, "count", 1)
5356
}
5457

58+
function test_multipleSendReview_copy_psbt_copies_base64_and_shows_status() {
59+
Clipboard.setText("")
60+
testWalletModel.setCreateUnsignedPsbtResult(true,
61+
"PSBT copied to clipboard.",
62+
"cHNidP8BAHECAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AaCGAQAAAAAAIgAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAR8AAAAAAAEfAAAAAA==")
63+
64+
const page = createTemporaryObject(multipleSendReviewComponent, this)
65+
verify(page !== null)
66+
67+
const copyButton = findChild(page, "multipleSendReviewCopyPsbtButton")
68+
const statusText = findChild(page, "multipleSendReviewPsbtStatusText")
69+
verify(copyButton !== null)
70+
verify(statusText !== null)
71+
72+
const callsBefore = testWalletModel.createUnsignedPsbtCalls
73+
copyButton.clicked()
74+
75+
compare(testWalletModel.createUnsignedPsbtCalls, callsBefore + 1)
76+
compare(statusText.text, "PSBT copied to clipboard.")
77+
compare(Clipboard.text, "cHNidP8BAHECAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AaCGAQAAAAAAIgAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAR8AAAAAAAEfAAAAAA==")
78+
}
79+
5580
function test_multipleSendReview_back_and_confirm_signals() {
5681
const page = createTemporaryObject(multipleSendReviewComponent, this)
5782
verify(page !== null)

0 commit comments

Comments
 (0)