// Copyright (c) 2015-2022 The Bitcoin Core developers // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include using wallet::AddWallet; using wallet::CWallet; using wallet::CreateMockWalletDatabase; using wallet::RemoveWallet; using wallet::WALLET_FLAG_DESCRIPTORS; using wallet::WALLET_FLAG_DISABLE_PRIVATE_KEYS; using wallet::WalletContext; using wallet::WalletDescriptor; using wallet::WalletRescanReserver; namespace { //! Press "Yes" or "Cancel" buttons in modal send confirmation dialog. void ConfirmSend(QString* text = nullptr, QMessageBox::StandardButton confirm_type = QMessageBox::Yes) { QTimer::singleShot(0, [text, confirm_type]() { for (QWidget* widget : QApplication::topLevelWidgets()) { if (widget->inherits("SendConfirmationDialog")) { SendConfirmationDialog* dialog = qobject_cast(widget); if (text) *text = dialog->text(); QAbstractButton* button = dialog->button(confirm_type); button->setEnabled(true); button->click(); } } }); } //! Send coins to address and return txid. uint256 SendCoins(CWallet& wallet, SendCoinsDialog& sendCoinsDialog, const CTxDestination& address, CAmount amount, bool rbf, QMessageBox::StandardButton confirm_type = QMessageBox::Yes) { QVBoxLayout* entries = sendCoinsDialog.findChild("entries"); SendCoinsEntry* entry = qobject_cast(entries->itemAt(0)->widget()); entry->findChild("payTo")->setText(QString::fromStdString(EncodeDestination(address))); entry->findChild("payAmount")->setValue(amount); sendCoinsDialog.findChild("frameFee") ->findChild("frameFeeSelection") ->findChild("optInRBF") ->setCheckState(rbf ? Qt::Checked : Qt::Unchecked); uint256 txid; boost::signals2::scoped_connection c(wallet.NotifyTransactionChanged.connect([&txid](const uint256& hash, ChangeType status) { if (status == CT_NEW) txid = hash; })); ConfirmSend(/*text=*/nullptr, confirm_type); bool invoked = QMetaObject::invokeMethod(&sendCoinsDialog, "sendButtonClicked", Q_ARG(bool, false)); assert(invoked); return txid; } //! Find index of txid in transaction list. QModelIndex FindTx(const QAbstractItemModel& model, const uint256& txid) { QString hash = QString::fromStdString(txid.ToString()); int rows = model.rowCount({}); for (int row = 0; row < rows; ++row) { QModelIndex index = model.index(row, 0, {}); if (model.data(index, TransactionTableModel::TxHashRole) == hash) { return index; } } return {}; } //! Invoke bumpfee on txid and check results. void BumpFee(TransactionView& view, const uint256& txid, bool expectDisabled, std::string expectError, bool cancel) { QTableView* table = view.findChild("transactionView"); QModelIndex index = FindTx(*table->selectionModel()->model(), txid); QVERIFY2(index.isValid(), "Could not find BumpFee txid"); // Select row in table, invoke context menu, and make sure bumpfee action is // enabled or disabled as expected. QAction* action = view.findChild("bumpFeeAction"); table->selectionModel()->select(index, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); action->setEnabled(expectDisabled); table->customContextMenuRequested({}); QCOMPARE(action->isEnabled(), !expectDisabled); action->setEnabled(true); QString text; if (expectError.empty()) { ConfirmSend(&text, cancel ? QMessageBox::Cancel : QMessageBox::Yes); } else { ConfirmMessage(&text, 0ms); } action->trigger(); QVERIFY(text.indexOf(QString::fromStdString(expectError)) != -1); } void CompareBalance(WalletModel& walletModel, CAmount expected_balance, QLabel* balance_label_to_check) { BitcoinUnit unit = walletModel.getOptionsModel()->getDisplayUnit(); QString balanceComparison = BitcoinUnits::formatWithUnit(unit, expected_balance, false, BitcoinUnits::SeparatorStyle::ALWAYS); QCOMPARE(balance_label_to_check->text().trimmed(), balanceComparison); } // Verify the 'useAvailableBalance' functionality. With and without manually selected coins. // Case 1: No coin control selected coins. // 'useAvailableBalance' should fill the amount edit box with the total available balance // Case 2: With coin control selected coins. // 'useAvailableBalance' should fill the amount edit box with the sum of the selected coins values. void VerifyUseAvailableBalance(SendCoinsDialog& sendCoinsDialog, const WalletModel& walletModel) { // Verify first entry amount and "useAvailableBalance" button QVBoxLayout* entries = sendCoinsDialog.findChild("entries"); QVERIFY(entries->count() == 1); // only one entry SendCoinsEntry* send_entry = qobject_cast(entries->itemAt(0)->widget()); QVERIFY(send_entry->getValue().amount == 0); // Now click "useAvailableBalance", check updated balance (the entire wallet balance should be set) Q_EMIT send_entry->useAvailableBalance(send_entry); QVERIFY(send_entry->getValue().amount == walletModel.getCachedBalance().balance); // Now manually select two coins and click on "useAvailableBalance". Then check updated balance // (only the sum of the selected coins should be set). int COINS_TO_SELECT = 2; auto coins = walletModel.wallet().listCoins(); CAmount sum_selected_coins = 0; int selected = 0; QVERIFY(coins.size() == 1); // context check, coins received only on one destination for (const auto& [outpoint, tx_out] : coins.begin()->second) { sendCoinsDialog.getCoinControl()->Select(outpoint); sum_selected_coins += tx_out.txout.nValue; if (++selected == COINS_TO_SELECT) break; } QVERIFY(selected == COINS_TO_SELECT); // Now that we have 2 coins selected, "useAvailableBalance" should update the balance label only with // the sum of them. Q_EMIT send_entry->useAvailableBalance(send_entry); QVERIFY(send_entry->getValue().amount == sum_selected_coins); } void SyncUpWallet(const std::shared_ptr& wallet, interfaces::Node& node) { WalletRescanReserver reserver(*wallet); reserver.reserve(); CWallet::ScanResult result = wallet->ScanForWalletTransactions(Params().GetConsensus().hashGenesisBlock, /*start_height=*/0, /*max_height=*/{}, reserver, /*fUpdate=*/true, /*save_progress=*/false); QCOMPARE(result.status, CWallet::ScanResult::SUCCESS); QCOMPARE(result.last_scanned_block, WITH_LOCK(node.context()->chainman->GetMutex(), return node.context()->chainman->ActiveChain().Tip()->GetBlockHash())); QVERIFY(result.last_failed_block.IsNull()); } std::shared_ptr SetupLegacyWatchOnlyWallet(interfaces::Node& node, TestChain100Setup& test) { std::shared_ptr wallet = std::make_shared(node.context()->chain.get(), "", CreateMockWalletDatabase()); wallet->LoadWallet(); { LOCK(wallet->cs_wallet); wallet->SetWalletFlag(WALLET_FLAG_DISABLE_PRIVATE_KEYS); wallet->SetupLegacyScriptPubKeyMan(); // Add watched key CPubKey pubKey = test.coinbaseKey.GetPubKey(); bool import_keys = wallet->ImportPubKeys({pubKey.GetID()}, {{pubKey.GetID(), pubKey}} , /*key_origins=*/{}, /*add_keypool=*/false, /*internal=*/false, /*timestamp=*/1); assert(import_keys); wallet->SetLastBlockProcessed(105, WITH_LOCK(node.context()->chainman->GetMutex(), return node.context()->chainman->ActiveChain().Tip()->GetBlockHash())); } SyncUpWallet(wallet, node); return wallet; } std::shared_ptr SetupDescriptorsWallet(interfaces::Node& node, TestChain100Setup& test) { std::shared_ptr wallet = std::make_shared(node.context()->chain.get(), "", CreateMockWalletDatabase()); wallet->LoadWallet(); LOCK(wallet->cs_wallet); wallet->SetWalletFlag(WALLET_FLAG_DESCRIPTORS); wallet->SetupDescriptorScriptPubKeyMans(); // Add the coinbase key FlatSigningProvider provider; std::string error; std::unique_ptr desc = Parse("combo(" + EncodeSecret(test.coinbaseKey) + ")", provider, error, /* require_checksum=*/ false); assert(desc); WalletDescriptor w_desc(std::move(desc), 0, 0, 1, 1); if (!wallet->AddWalletDescriptor(w_desc, provider, "", false)) assert(false); CTxDestination dest = GetDestinationForKey(test.coinbaseKey.GetPubKey(), wallet->m_default_address_type); wallet->SetAddressBook(dest, "", "receive"); wallet->SetLastBlockProcessed(105, WITH_LOCK(node.context()->chainman->GetMutex(), return node.context()->chainman->ActiveChain().Tip()->GetBlockHash())); SyncUpWallet(wallet, node); wallet->SetBroadcastTransactions(true); return wallet; } struct MiniGUI { public: SendCoinsDialog sendCoinsDialog; TransactionView transactionView; OptionsModel optionsModel; std::unique_ptr clientModel; std::unique_ptr walletModel; MiniGUI(interfaces::Node& node, const PlatformStyle* platformStyle) : sendCoinsDialog(platformStyle), transactionView(platformStyle), optionsModel(node) { bilingual_str error; QVERIFY(optionsModel.Init(error)); clientModel = std::make_unique(node, &optionsModel); } void initModelForWallet(interfaces::Node& node, const std::shared_ptr& wallet, const PlatformStyle* platformStyle) { WalletContext& context = *node.walletLoader().context(); AddWallet(context, wallet); walletModel = std::make_unique(interfaces::MakeWallet(context, wallet), *clientModel, platformStyle); RemoveWallet(context, wallet, /* load_on_start= */ std::nullopt); sendCoinsDialog.setModel(walletModel.get()); transactionView.setModel(walletModel.get()); } }; //! Simple qt wallet tests. // // Test widgets can be debugged interactively calling show() on them and // manually running the event loop, e.g.: // // sendCoinsDialog.show(); // QEventLoop().exec(); // // This also requires overriding the default minimal Qt platform: // // QT_QPA_PLATFORM=xcb src/qt/test/test_bitcoin-qt # Linux // QT_QPA_PLATFORM=windows src/qt/test/test_bitcoin-qt # Windows // QT_QPA_PLATFORM=cocoa src/qt/test/test_bitcoin-qt # macOS void TestGUI(interfaces::Node& node, const std::shared_ptr& wallet) { // Create widgets for sending coins and listing transactions. std::unique_ptr platformStyle(PlatformStyle::instantiate("other")); MiniGUI mini_gui(node, platformStyle.get()); mini_gui.initModelForWallet(node, wallet, platformStyle.get()); WalletModel& walletModel = *mini_gui.walletModel; SendCoinsDialog& sendCoinsDialog = mini_gui.sendCoinsDialog; TransactionView& transactionView = mini_gui.transactionView; // Update walletModel cached balance which will trigger an update for the 'labelBalance' QLabel. walletModel.pollBalanceChanged(); // Check balance in send dialog CompareBalance(walletModel, walletModel.wallet().getBalance(), sendCoinsDialog.findChild("labelBalance")); // Check 'UseAvailableBalance' functionality VerifyUseAvailableBalance(sendCoinsDialog, walletModel); // Send two transactions, and verify they are added to transaction list. TransactionTableModel* transactionTableModel = walletModel.getTransactionTableModel(); QCOMPARE(transactionTableModel->rowCount({}), 105); uint256 txid1 = SendCoins(*wallet.get(), sendCoinsDialog, PKHash(), 5 * COIN, /*rbf=*/false); uint256 txid2 = SendCoins(*wallet.get(), sendCoinsDialog, PKHash(), 10 * COIN, /*rbf=*/true); // Transaction table model updates on a QueuedConnection, so process events to ensure it's updated. qApp->processEvents(); QCOMPARE(transactionTableModel->rowCount({}), 107); QVERIFY(FindTx(*transactionTableModel, txid1).isValid()); QVERIFY(FindTx(*transactionTableModel, txid2).isValid()); // Call bumpfee. Test disabled, canceled, enabled, then failing cases. BumpFee(transactionView, txid1, /*expectDisabled=*/true, /*expectError=*/"not BIP 125 replaceable", /*cancel=*/false); BumpFee(transactionView, txid2, /*expectDisabled=*/false, /*expectError=*/{}, /*cancel=*/true); BumpFee(transactionView, txid2, /*expectDisabled=*/false, /*expectError=*/{}, /*cancel=*/false); BumpFee(transactionView, txid2, /*expectDisabled=*/true, /*expectError=*/"already bumped", /*cancel=*/false); // Check current balance on OverviewPage OverviewPage overviewPage(platformStyle.get()); overviewPage.setWalletModel(&walletModel); walletModel.pollBalanceChanged(); // Manual balance polling update CompareBalance(walletModel, walletModel.wallet().getBalance(), overviewPage.findChild("labelBalance")); // Check Request Payment button ReceiveCoinsDialog receiveCoinsDialog(platformStyle.get()); receiveCoinsDialog.setModel(&walletModel); RecentRequestsTableModel* requestTableModel = walletModel.getRecentRequestsTableModel(); // Label input QLineEdit* labelInput = receiveCoinsDialog.findChild("reqLabel"); labelInput->setText("TEST_LABEL_1"); // Amount input BitcoinAmountField* amountInput = receiveCoinsDialog.findChild("reqAmount"); amountInput->setValue(1); // Message input QLineEdit* messageInput = receiveCoinsDialog.findChild("reqMessage"); messageInput->setText("TEST_MESSAGE_1"); int initialRowCount = requestTableModel->rowCount({}); QPushButton* requestPaymentButton = receiveCoinsDialog.findChild("receiveButton"); requestPaymentButton->click(); QString address; for (QWidget* widget : QApplication::topLevelWidgets()) { if (widget->inherits("ReceiveRequestDialog")) { ReceiveRequestDialog* receiveRequestDialog = qobject_cast(widget); QCOMPARE(receiveRequestDialog->QObject::findChild("payment_header")->text(), QString("Payment information")); QCOMPARE(receiveRequestDialog->QObject::findChild("uri_tag")->text(), QString("URI:")); QString uri = receiveRequestDialog->QObject::findChild("uri_content")->text(); QCOMPARE(uri.count("bitcoin:"), 2); QCOMPARE(receiveRequestDialog->QObject::findChild("address_tag")->text(), QString("Address:")); QVERIFY(address.isEmpty()); address = receiveRequestDialog->QObject::findChild("address_content")->text(); QVERIFY(!address.isEmpty()); QCOMPARE(uri.count("amount=0.00000001"), 2); QCOMPARE(receiveRequestDialog->QObject::findChild("amount_tag")->text(), QString("Amount:")); QCOMPARE(receiveRequestDialog->QObject::findChild("amount_content")->text(), QString::fromStdString("0.00000001 " + CURRENCY_UNIT)); QCOMPARE(uri.count("label=TEST_LABEL_1"), 2); QCOMPARE(receiveRequestDialog->QObject::findChild("label_tag")->text(), QString("Label:")); QCOMPARE(receiveRequestDialog->QObject::findChild("label_content")->text(), QString("TEST_LABEL_1")); QCOMPARE(uri.count("message=TEST_MESSAGE_1"), 2); QCOMPARE(receiveRequestDialog->QObject::findChild("message_tag")->text(), QString("Message:")); QCOMPARE(receiveRequestDialog->QObject::findChild("message_content")->text(), QString("TEST_MESSAGE_1")); } } // Clear button QPushButton* clearButton = receiveCoinsDialog.findChild("clearButton"); clearButton->click(); QCOMPARE(labelInput->text(), QString("")); QCOMPARE(amountInput->value(), CAmount(0)); QCOMPARE(messageInput->text(), QString("")); // Check addition to history int currentRowCount = requestTableModel->rowCount({}); QCOMPARE(currentRowCount, initialRowCount+1); // Check addition to wallet std::vector requests = walletModel.wallet().getAddressReceiveRequests(); QCOMPARE(requests.size(), size_t{1}); RecentRequestEntry entry; DataStream{MakeUCharSpan(requests[0])} >> entry; QCOMPARE(entry.nVersion, int{1}); QCOMPARE(entry.id, int64_t{1}); QVERIFY(entry.date.isValid()); QCOMPARE(entry.recipient.address, address); QCOMPARE(entry.recipient.label, QString{"TEST_LABEL_1"}); QCOMPARE(entry.recipient.amount, CAmount{1}); QCOMPARE(entry.recipient.message, QString{"TEST_MESSAGE_1"}); QCOMPARE(entry.recipient.sPaymentRequest, std::string{}); QCOMPARE(entry.recipient.authenticatedMerchant, QString{}); // Check Remove button QTableView* table = receiveCoinsDialog.findChild("recentRequestsView"); table->selectRow(currentRowCount-1); QPushButton* removeRequestButton = receiveCoinsDialog.findChild("removeRequestButton"); removeRequestButton->click(); QCOMPARE(requestTableModel->rowCount({}), currentRowCount-1); // Check removal from wallet QCOMPARE(walletModel.wallet().getAddressReceiveRequests().size(), size_t{0}); } void TestGUIWatchOnly(interfaces::Node& node, TestChain100Setup& test) { const std::shared_ptr& wallet = SetupLegacyWatchOnlyWallet(node, test); // Create widgets and init models std::unique_ptr platformStyle(PlatformStyle::instantiate("other")); MiniGUI mini_gui(node, platformStyle.get()); mini_gui.initModelForWallet(node, wallet, platformStyle.get()); WalletModel& walletModel = *mini_gui.walletModel; SendCoinsDialog& sendCoinsDialog = mini_gui.sendCoinsDialog; // Update walletModel cached balance which will trigger an update for the 'labelBalance' QLabel. walletModel.pollBalanceChanged(); // Check balance in send dialog CompareBalance(walletModel, walletModel.wallet().getBalances().watch_only_balance, sendCoinsDialog.findChild("labelBalance")); // Set change address sendCoinsDialog.getCoinControl()->destChange = GetDestinationForKey(test.coinbaseKey.GetPubKey(), OutputType::LEGACY); // Time to reject "save" PSBT dialog ('SendCoins' locks the main thread until the dialog receives the event). QTimer timer; timer.setInterval(500); QObject::connect(&timer, &QTimer::timeout, [&](){ for (QWidget* widget : QApplication::topLevelWidgets()) { if (widget->inherits("QMessageBox")) { QMessageBox* dialog = qobject_cast(widget); QAbstractButton* button = dialog->button(QMessageBox::Discard); button->setEnabled(true); button->click(); timer.stop(); break; } } }); timer.start(500); // Send tx and verify PSBT copied to the clipboard. SendCoins(*wallet.get(), sendCoinsDialog, PKHash(), 5 * COIN, /*rbf=*/false, QMessageBox::Save); const std::string& psbt_string = QApplication::clipboard()->text().toStdString(); QVERIFY(!psbt_string.empty()); // Decode psbt std::optional> decoded_psbt = DecodeBase64(psbt_string); QVERIFY(decoded_psbt); PartiallySignedTransaction psbt; std::string err; QVERIFY(DecodeRawPSBT(psbt, MakeByteSpan(*decoded_psbt), err)); } void TestGUI(interfaces::Node& node) { // Set up wallet and chain with 105 blocks (5 mature blocks for spending). TestChain100Setup test; for (int i = 0; i < 5; ++i) { test.CreateAndProcessBlock({}, GetScriptForRawPubKey(test.coinbaseKey.GetPubKey())); } auto wallet_loader = interfaces::MakeWalletLoader(*test.m_node.chain, *Assert(test.m_node.args)); test.m_node.wallet_loader = wallet_loader.get(); node.setContext(&test.m_node); // "Full" GUI tests, use descriptor wallet const std::shared_ptr& desc_wallet = SetupDescriptorsWallet(node, test); TestGUI(node, desc_wallet); // Legacy watch-only wallet test // Verify PSBT creation. TestGUIWatchOnly(node, test); } } // namespace void WalletTests::walletTests() { #ifdef Q_OS_MACOS if (QApplication::platformName() == "minimal") { // Disable for mac on "minimal" platform to avoid crashes inside the Qt // framework when it tries to look up unimplemented cocoa functions, // and fails to handle returned nulls // (https://bugreports.qt.io/browse/QTBUG-49686). QWARN("Skipping WalletTests on mac build with 'minimal' platform set due to Qt bugs. To run AppTests, invoke " "with 'QT_QPA_PLATFORM=cocoa test_bitcoin-qt' on mac, or else use a linux or windows build."); return; } #endif TestGUI(m_node); }