wallettests.cpp raw
1 // Copyright (c) 2015-present 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 <qt/test/wallettests.h>
6 #include <qt/test/util.h>
7
8 #include <wallet/coincontrol.h>
9 #include <interfaces/chain.h>
10 #include <interfaces/node.h>
11 #include <key_io.h>
12 #include <qt/bitcoinamountfield.h>
13 #include <qt/bitcoinunits.h>
14 #include <qt/clientmodel.h>
15 #include <qt/optionsmodel.h>
16 #include <qt/overviewpage.h>
17 #include <qt/platformstyle.h>
18 #include <qt/qvalidatedlineedit.h>
19 #include <qt/receivecoinsdialog.h>
20 #include <qt/receiverequestdialog.h>
21 #include <qt/recentrequeststablemodel.h>
22 #include <qt/sendcoinsdialog.h>
23 #include <qt/sendcoinsentry.h>
24 #include <qt/transactiontablemodel.h>
25 #include <qt/transactionview.h>
26 #include <qt/walletmodel.h>
27 #include <script/solver.h>
28 #include <test/util/setup_common.h>
29 #include <validation.h>
30 #include <wallet/test/util.h>
31 #include <wallet/wallet.h>
32
33 #include <chrono>
34 #include <memory>
35
36 #include <QAbstractButton>
37 #include <QAction>
38 #include <QApplication>
39 #include <QCheckBox>
40 #include <QClipboard>
41 #include <QObject>
42 #include <QPushButton>
43 #include <QTimer>
44 #include <QVBoxLayout>
45 #include <QTextEdit>
46 #include <QListView>
47 #include <QDialogButtonBox>
48
49 using wallet::AddWallet;
50 using wallet::CWallet;
51 using wallet::CreateMockableWalletDatabase;
52 using wallet::RemoveWallet;
53 using wallet::WALLET_FLAG_DESCRIPTORS;
54 using wallet::WALLET_FLAG_DISABLE_PRIVATE_KEYS;
55 using wallet::WalletContext;
56 using wallet::WalletDescriptor;
57 using wallet::WalletRescanReserver;
58
59 namespace
60 {
61 //! Press "Yes" or "Cancel" buttons in modal send confirmation dialog.
62 void ConfirmSend(QString* text = nullptr, QMessageBox::StandardButton confirm_type = QMessageBox::Yes)
63 {
64 QTimer::singleShot(0, [text, confirm_type]() {
65 for (QWidget* widget : QApplication::topLevelWidgets()) {
66 if (widget->inherits("SendConfirmationDialog")) {
67 SendConfirmationDialog* dialog = qobject_cast<SendConfirmationDialog*>(widget);
68 if (text) *text = dialog->text();
69 QAbstractButton* button = dialog->button(confirm_type);
70 button->setEnabled(true);
71 button->click();
72 }
73 }
74 });
75 }
76
77 //! Send coins to address and return txid.
78 Txid SendCoins(CWallet& wallet, SendCoinsDialog& sendCoinsDialog, const CTxDestination& address, CAmount amount,
79 QMessageBox::StandardButton confirm_type = QMessageBox::Yes)
80 {
81 QVBoxLayout* entries = sendCoinsDialog.findChild<QVBoxLayout*>("entries");
82 SendCoinsEntry* entry = qobject_cast<SendCoinsEntry*>(entries->itemAt(0)->widget());
83 entry->findChild<QValidatedLineEdit*>("payTo")->setText(QString::fromStdString(EncodeDestination(address)));
84 entry->findChild<BitcoinAmountField*>("payAmount")->setValue(amount);
85 Txid txid;
86 btcsignals::scoped_connection c(wallet.NotifyTransactionChanged.connect([&txid](const Txid& hash, ChangeType status) {
87 if (status == CT_NEW) txid = hash;
88 }));
89 ConfirmSend(/*text=*/nullptr, confirm_type);
90 bool invoked = QMetaObject::invokeMethod(&sendCoinsDialog, "sendButtonClicked", Q_ARG(bool, false));
91 assert(invoked);
92 return txid;
93 }
94
95 //! Find index of txid in transaction list.
96 QModelIndex FindTx(const QAbstractItemModel& model, const Txid& txid)
97 {
98 QString hash = QString::fromStdString(txid.ToString());
99 int rows = model.rowCount({});
100 for (int row = 0; row < rows; ++row) {
101 QModelIndex index = model.index(row, 0, {});
102 if (model.data(index, TransactionTableModel::TxHashRole) == hash) {
103 return index;
104 }
105 }
106 return {};
107 }
108
109 //! Invoke bumpfee on txid and check results.
110 void BumpFee(TransactionView& view, const Txid& txid, bool expectDisabled, std::string expectError, bool cancel)
111 {
112 QTableView* table = view.findChild<QTableView*>("transactionView");
113 QModelIndex index = FindTx(*table->selectionModel()->model(), txid);
114 QVERIFY2(index.isValid(), "Could not find BumpFee txid");
115
116 // Select row in table, invoke context menu, and make sure bumpfee action is
117 // enabled or disabled as expected.
118 QAction* action = view.findChild<QAction*>("bumpFeeAction");
119 table->selectionModel()->select(index, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows);
120 action->setEnabled(expectDisabled);
121 table->customContextMenuRequested({});
122 QCOMPARE(action->isEnabled(), !expectDisabled);
123
124 action->setEnabled(true);
125 QString text;
126 if (expectError.empty()) {
127 ConfirmSend(&text, cancel ? QMessageBox::Cancel : QMessageBox::Yes);
128 } else {
129 ConfirmMessage(&text, 0ms);
130 }
131 action->trigger();
132 QVERIFY(text.indexOf(QString::fromStdString(expectError)) != -1);
133 }
134
135 void CompareBalance(WalletModel& walletModel, CAmount expected_balance, QLabel* balance_label_to_check)
136 {
137 BitcoinUnit unit = walletModel.getOptionsModel()->getDisplayUnit();
138 QString balanceComparison = BitcoinUnits::formatWithUnit(unit, expected_balance, false, BitcoinUnits::SeparatorStyle::ALWAYS);
139 QCOMPARE(balance_label_to_check->text().trimmed(), balanceComparison);
140 }
141
142 // Verify the 'useAvailableBalance' functionality. With and without manually selected coins.
143 // Case 1: No coin control selected coins.
144 // 'useAvailableBalance' should fill the amount edit box with the total available balance
145 // Case 2: With coin control selected coins.
146 // 'useAvailableBalance' should fill the amount edit box with the sum of the selected coins values.
147 void VerifyUseAvailableBalance(SendCoinsDialog& sendCoinsDialog, const WalletModel& walletModel)
148 {
149 // Verify first entry amount and "useAvailableBalance" button
150 QVBoxLayout* entries = sendCoinsDialog.findChild<QVBoxLayout*>("entries");
151 QVERIFY(entries->count() == 1); // only one entry
152 SendCoinsEntry* send_entry = qobject_cast<SendCoinsEntry*>(entries->itemAt(0)->widget());
153 QVERIFY(send_entry->getValue().amount == 0);
154 // Now click "useAvailableBalance", check updated balance (the entire wallet balance should be set)
155 Q_EMIT send_entry->useAvailableBalance(send_entry);
156 QVERIFY(send_entry->getValue().amount == walletModel.getCachedBalance().balance);
157
158 // Now manually select two coins and click on "useAvailableBalance". Then check updated balance
159 // (only the sum of the selected coins should be set).
160 int COINS_TO_SELECT = 2;
161 auto coins = walletModel.wallet().listCoins();
162 CAmount sum_selected_coins = 0;
163 int selected = 0;
164 QVERIFY(coins.size() == 1); // context check, coins received only on one destination
165 for (const auto& [outpoint, tx_out] : coins.begin()->second) {
166 sendCoinsDialog.getCoinControl()->Select(outpoint);
167 sum_selected_coins += tx_out.txout.nValue;
168 if (++selected == COINS_TO_SELECT) break;
169 }
170 QVERIFY(selected == COINS_TO_SELECT);
171
172 // Now that we have 2 coins selected, "useAvailableBalance" should update the balance label only with
173 // the sum of them.
174 Q_EMIT send_entry->useAvailableBalance(send_entry);
175 QVERIFY(send_entry->getValue().amount == sum_selected_coins);
176 }
177
178 void SyncUpWallet(const std::shared_ptr<CWallet>& wallet, interfaces::Node& node)
179 {
180 WalletRescanReserver reserver(*wallet);
181 reserver.reserve();
182 CWallet::ScanResult result = wallet->ScanForWalletTransactions(Params().GetConsensus().hashGenesisBlock, /*start_height=*/0, /*max_height=*/{}, reserver, /*save_progress=*/false);
183 QCOMPARE(result.status, CWallet::ScanResult::SUCCESS);
184 QCOMPARE(result.last_scanned_block, WITH_LOCK(node.context()->chainman->GetMutex(), return node.context()->chainman->ActiveChain().Tip()->GetBlockHash()));
185 QVERIFY(result.last_failed_block.IsNull());
186 }
187
188 std::shared_ptr<CWallet> SetupDescriptorsWallet(interfaces::Node& node, TestChain100Setup& test, bool watch_only = false)
189 {
190 std::shared_ptr<CWallet> wallet = std::make_shared<CWallet>(node.context()->chain.get(), "", CreateMockableWalletDatabase());
191 LOCK(wallet->cs_wallet);
192 wallet->SetWalletFlag(WALLET_FLAG_DESCRIPTORS);
193 if (watch_only) {
194 wallet->SetWalletFlag(WALLET_FLAG_DISABLE_PRIVATE_KEYS);
195 } else {
196 wallet->SetupDescriptorScriptPubKeyMans();
197 }
198
199 // Add the coinbase key
200 FlatSigningProvider provider;
201 std::string error;
202 std::string key_str;
203 if (watch_only) {
204 key_str = HexStr(test.coinbaseKey.GetPubKey());
205 } else {
206 key_str = EncodeSecret(test.coinbaseKey);
207 }
208 auto descs = Parse("combo(" + key_str + ")", provider, error, /* require_checksum=*/ false);
209 assert(!descs.empty());
210 assert(descs.size() == 1);
211 auto& desc = descs.at(0);
212 WalletDescriptor w_desc(std::move(desc), 0, 0, 1, 1);
213 Assert(wallet->AddWalletDescriptor(w_desc, provider, "", false));
214 const PKHash dest{test.coinbaseKey.GetPubKey()};
215 wallet->SetAddressBook(dest, "", wallet::AddressPurpose::RECEIVE);
216 wallet->SetLastBlockProcessed(105, WITH_LOCK(node.context()->chainman->GetMutex(), return node.context()->chainman->ActiveChain().Tip()->GetBlockHash()));
217 SyncUpWallet(wallet, node);
218 wallet->SetBroadcastTransactions(true);
219 return wallet;
220 }
221
222 struct MiniGUI {
223 public:
224 SendCoinsDialog sendCoinsDialog;
225 TransactionView transactionView;
226 OptionsModel optionsModel;
227 std::unique_ptr<ClientModel> clientModel;
228 std::unique_ptr<WalletModel> walletModel;
229
230 MiniGUI(interfaces::Node& node, const PlatformStyle* platformStyle) : sendCoinsDialog(platformStyle), transactionView(platformStyle), optionsModel(node) {
231 bilingual_str error;
232 QVERIFY(optionsModel.Init(error));
233 clientModel = std::make_unique<ClientModel>(node, &optionsModel);
234 }
235
236 void initModelForWallet(interfaces::Node& node, const std::shared_ptr<CWallet>& wallet, const PlatformStyle* platformStyle)
237 {
238 WalletContext& context = *node.walletLoader().context();
239 AddWallet(context, wallet);
240 walletModel = std::make_unique<WalletModel>(interfaces::MakeWallet(context, wallet), *clientModel, platformStyle);
241 RemoveWallet(context, wallet, /* load_on_start= */ std::nullopt);
242 sendCoinsDialog.setModel(walletModel.get());
243 transactionView.setModel(walletModel.get());
244 }
245
246 };
247
248 //! Simple qt wallet tests.
249 //
250 // Test widgets can be debugged interactively calling show() on them and
251 // manually running the event loop, e.g.:
252 //
253 // sendCoinsDialog.show();
254 // QEventLoop().exec();
255 //
256 // This also requires overriding the default minimal Qt platform:
257 //
258 // QT_QPA_PLATFORM=xcb build/bin/test_bitcoin-qt # Linux
259 // QT_QPA_PLATFORM=windows build/bin/test_bitcoin-qt # Windows
260 // QT_QPA_PLATFORM=cocoa build/bin/test_bitcoin-qt # macOS
261 void TestGUI(interfaces::Node& node, const std::shared_ptr<CWallet>& wallet)
262 {
263 // Create widgets for sending coins and listing transactions.
264 std::unique_ptr<const PlatformStyle> platformStyle(PlatformStyle::instantiate("other"));
265 MiniGUI mini_gui(node, platformStyle.get());
266 mini_gui.initModelForWallet(node, wallet, platformStyle.get());
267 WalletModel& walletModel = *mini_gui.walletModel;
268 SendCoinsDialog& sendCoinsDialog = mini_gui.sendCoinsDialog;
269 TransactionView& transactionView = mini_gui.transactionView;
270
271 // Update walletModel cached balance which will trigger an update for the 'labelBalance' QLabel.
272 walletModel.pollBalanceChanged();
273 // Check balance in send dialog
274 CompareBalance(walletModel, walletModel.wallet().getBalance(), sendCoinsDialog.findChild<QLabel*>("labelBalance"));
275
276 // Check 'UseAvailableBalance' functionality
277 VerifyUseAvailableBalance(sendCoinsDialog, walletModel);
278
279 // Send two transactions, and verify they are added to transaction list.
280 TransactionTableModel* transactionTableModel = walletModel.getTransactionTableModel();
281 QCOMPARE(transactionTableModel->rowCount({}), 105);
282 Txid txid1 = SendCoins(*wallet.get(), sendCoinsDialog, PKHash(), 5 * COIN);
283 Txid txid2 = SendCoins(*wallet.get(), sendCoinsDialog, PKHash(), 10 * COIN);
284 // Transaction table model updates on a QueuedConnection, so process events to ensure it's updated.
285 qApp->processEvents();
286 QCOMPARE(transactionTableModel->rowCount({}), 107);
287 QVERIFY(FindTx(*transactionTableModel, txid1).isValid());
288 QVERIFY(FindTx(*transactionTableModel, txid2).isValid());
289
290 // Call bumpfee. Test canceled fullrbf bump, canceled bip-125-rbf bump, passing bump, and then failing bump.
291 BumpFee(transactionView, txid1, /*expectDisabled=*/false, /*expectError=*/{}, /*cancel=*/true);
292 BumpFee(transactionView, txid2, /*expectDisabled=*/false, /*expectError=*/{}, /*cancel=*/true);
293 BumpFee(transactionView, txid2, /*expectDisabled=*/false, /*expectError=*/{}, /*cancel=*/false);
294 BumpFee(transactionView, txid2, /*expectDisabled=*/true, /*expectError=*/"already bumped", /*cancel=*/false);
295
296 // Check current balance on OverviewPage
297 OverviewPage overviewPage(platformStyle.get());
298 overviewPage.setWalletModel(&walletModel);
299 walletModel.pollBalanceChanged(); // Manual balance polling update
300 CompareBalance(walletModel, walletModel.wallet().getBalance(), overviewPage.findChild<QLabel*>("labelBalance"));
301
302 // Check Request Payment button
303 ReceiveCoinsDialog receiveCoinsDialog(platformStyle.get());
304 receiveCoinsDialog.setModel(&walletModel);
305 RecentRequestsTableModel* requestTableModel = walletModel.getRecentRequestsTableModel();
306
307 // Label input
308 QLineEdit* labelInput = receiveCoinsDialog.findChild<QLineEdit*>("reqLabel");
309 labelInput->setText("TEST_LABEL_1");
310
311 // Amount input
312 BitcoinAmountField* amountInput = receiveCoinsDialog.findChild<BitcoinAmountField*>("reqAmount");
313 amountInput->setValue(1);
314
315 // Message input
316 QLineEdit* messageInput = receiveCoinsDialog.findChild<QLineEdit*>("reqMessage");
317 messageInput->setText("TEST_MESSAGE_1");
318 int initialRowCount = requestTableModel->rowCount({});
319 QPushButton* requestPaymentButton = receiveCoinsDialog.findChild<QPushButton*>("receiveButton");
320 requestPaymentButton->click();
321 QString address;
322 for (QWidget* widget : QApplication::topLevelWidgets()) {
323 if (widget->inherits("ReceiveRequestDialog")) {
324 ReceiveRequestDialog* receiveRequestDialog = qobject_cast<ReceiveRequestDialog*>(widget);
325 QCOMPARE(receiveRequestDialog->QObject::findChild<QLabel*>("payment_header")->text(), QString("Payment information"));
326 QCOMPARE(receiveRequestDialog->QObject::findChild<QLabel*>("uri_tag")->text(), QString("URI:"));
327 QString uri = receiveRequestDialog->QObject::findChild<QLabel*>("uri_content")->text();
328 QCOMPARE(uri.count("bitcoin:"), 2);
329 QCOMPARE(receiveRequestDialog->QObject::findChild<QLabel*>("address_tag")->text(), QString("Address:"));
330 QVERIFY(address.isEmpty());
331 address = receiveRequestDialog->QObject::findChild<QLabel*>("address_content")->text();
332 QVERIFY(!address.isEmpty());
333
334 QCOMPARE(uri.count("amount=0.00000001"), 2);
335 QCOMPARE(receiveRequestDialog->QObject::findChild<QLabel*>("amount_tag")->text(), QString("Amount:"));
336 QCOMPARE(receiveRequestDialog->QObject::findChild<QLabel*>("amount_content")->text(), QString::fromStdString("0.00000001 " + CURRENCY_UNIT));
337
338 QCOMPARE(uri.count("label=TEST_LABEL_1"), 2);
339 QCOMPARE(receiveRequestDialog->QObject::findChild<QLabel*>("label_tag")->text(), QString("Label:"));
340 QCOMPARE(receiveRequestDialog->QObject::findChild<QLabel*>("label_content")->text(), QString("TEST_LABEL_1"));
341
342 QCOMPARE(uri.count("message=TEST_MESSAGE_1"), 2);
343 QCOMPARE(receiveRequestDialog->QObject::findChild<QLabel*>("message_tag")->text(), QString("Message:"));
344 QCOMPARE(receiveRequestDialog->QObject::findChild<QLabel*>("message_content")->text(), QString("TEST_MESSAGE_1"));
345 }
346 }
347
348 // Clear button
349 QPushButton* clearButton = receiveCoinsDialog.findChild<QPushButton*>("clearButton");
350 clearButton->click();
351 QCOMPARE(labelInput->text(), QString(""));
352 QCOMPARE(amountInput->value(), CAmount(0));
353 QCOMPARE(messageInput->text(), QString(""));
354
355 // Check addition to history
356 int currentRowCount = requestTableModel->rowCount({});
357 QCOMPARE(currentRowCount, initialRowCount+1);
358
359 // Check addition to wallet
360 std::vector<std::string> requests = walletModel.wallet().getAddressReceiveRequests();
361 QCOMPARE(requests.size(), size_t{1});
362 RecentRequestEntry entry;
363 SpanReader{MakeByteSpan(requests[0])} >> entry;
364 QCOMPARE(entry.nVersion, int{1});
365 QCOMPARE(entry.id, int64_t{1});
366 QVERIFY(entry.date.isValid());
367 QCOMPARE(entry.recipient.address, address);
368 QCOMPARE(entry.recipient.label, QString{"TEST_LABEL_1"});
369 QCOMPARE(entry.recipient.amount, CAmount{1});
370 QCOMPARE(entry.recipient.message, QString{"TEST_MESSAGE_1"});
371 QCOMPARE(entry.recipient.sPaymentRequest, std::string{});
372 QCOMPARE(entry.recipient.authenticatedMerchant, QString{});
373
374 // Check Remove button
375 QTableView* table = receiveCoinsDialog.findChild<QTableView*>("recentRequestsView");
376 table->selectRow(currentRowCount-1);
377 QPushButton* removeRequestButton = receiveCoinsDialog.findChild<QPushButton*>("removeRequestButton");
378 removeRequestButton->click();
379 QCOMPARE(requestTableModel->rowCount({}), currentRowCount-1);
380
381 // Check removal from wallet
382 QCOMPARE(walletModel.wallet().getAddressReceiveRequests().size(), size_t{0});
383 }
384
385 void TestGUIWatchOnly(interfaces::Node& node, TestChain100Setup& test)
386 {
387 const std::shared_ptr<CWallet>& wallet = SetupDescriptorsWallet(node, test, /*watch_only=*/true);
388
389 // Create widgets and init models
390 std::unique_ptr<const PlatformStyle> platformStyle(PlatformStyle::instantiate("other"));
391 MiniGUI mini_gui(node, platformStyle.get());
392 mini_gui.initModelForWallet(node, wallet, platformStyle.get());
393 WalletModel& walletModel = *mini_gui.walletModel;
394 SendCoinsDialog& sendCoinsDialog = mini_gui.sendCoinsDialog;
395
396 // Update walletModel cached balance which will trigger an update for the 'labelBalance' QLabel.
397 walletModel.pollBalanceChanged();
398 // Check balance in send dialog
399 CompareBalance(walletModel, walletModel.wallet().getBalances().balance,
400 sendCoinsDialog.findChild<QLabel*>("labelBalance"));
401
402 // Set change address
403 sendCoinsDialog.getCoinControl()->destChange = PKHash{test.coinbaseKey.GetPubKey()};
404
405 // Time to reject "save" PSBT dialog ('SendCoins' locks the main thread until the dialog receives the event).
406 QTimer timer;
407 timer.setInterval(500);
408 QObject::connect(&timer, &QTimer::timeout, [&](){
409 for (QWidget* widget : QApplication::topLevelWidgets()) {
410 if (widget->inherits("QMessageBox") && widget->objectName().compare("psbt_copied_message") == 0) {
411 QMessageBox* dialog = qobject_cast<QMessageBox*>(widget);
412 QAbstractButton* button = dialog->button(QMessageBox::Discard);
413 button->setEnabled(true);
414 button->click();
415 timer.stop();
416 break;
417 }
418 }
419 });
420 timer.start(500);
421
422 // Send tx and verify PSBT copied to the clipboard.
423 SendCoins(*wallet.get(), sendCoinsDialog, PKHash(), 5 * COIN, QMessageBox::Save);
424 const std::string& psbt_string = QApplication::clipboard()->text().toStdString();
425 QVERIFY(!psbt_string.empty());
426
427 // Decode psbt
428 std::optional<std::vector<unsigned char>> decoded_psbt = DecodeBase64(psbt_string);
429 QVERIFY(decoded_psbt);
430 util::Result<PartiallySignedTransaction> psbt = DecodeRawPSBT(MakeByteSpan(*decoded_psbt));
431 QVERIFY(psbt);
432 }
433
434 void TestGUI(interfaces::Node& node)
435 {
436 // Set up wallet and chain with 105 blocks (5 mature blocks for spending).
437 TestChain100Setup test;
438 for (int i = 0; i < 5; ++i) {
439 test.CreateAndProcessBlock({}, GetScriptForRawPubKey(test.coinbaseKey.GetPubKey()));
440 }
441 auto wallet_loader = interfaces::MakeWalletLoader(*test.m_node.chain, *Assert(test.m_node.args));
442 test.m_node.wallet_loader = wallet_loader.get();
443 node.setContext(&test.m_node);
444
445 // "Full" GUI tests, use descriptor wallet
446 const std::shared_ptr<CWallet>& desc_wallet = SetupDescriptorsWallet(node, test);
447 TestGUI(node, desc_wallet);
448
449 // Legacy watch-only wallet test
450 // Verify PSBT creation.
451 TestGUIWatchOnly(node, test);
452 }
453
454 } // namespace
455
456 void WalletTests::walletTests()
457 {
458 #ifdef Q_OS_MACOS
459 if (QApplication::platformName() == "minimal") {
460 // Disable for mac on "minimal" platform to avoid crashes inside the Qt
461 // framework when it tries to look up unimplemented cocoa functions,
462 // and fails to handle returned nulls
463 // (https://bugreports.qt.io/browse/QTBUG-49686).
464 qWarning() << "Skipping WalletTests on mac build with 'minimal' platform set due to Qt bugs. To run AppTests, invoke "
465 "with 'QT_QPA_PLATFORM=cocoa test_bitcoin-qt' on mac, or else use a linux or windows build.";
466 return;
467 }
468 #endif
469 TestGUI(m_node);
470 }
471