wallettests.cpp raw

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