bitcoin/src/qt/test/wallettests.cpp
2025-04-29 11:53:03 +02:00

477 lines
21 KiB
C++

// 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 <qt/test/wallettests.h>
#include <qt/test/util.h>
#include <wallet/coincontrol.h>
#include <interfaces/chain.h>
#include <interfaces/node.h>
#include <key_io.h>
#include <qt/bitcoinamountfield.h>
#include <qt/bitcoinunits.h>
#include <qt/clientmodel.h>
#include <qt/optionsmodel.h>
#include <qt/overviewpage.h>
#include <qt/platformstyle.h>
#include <qt/qvalidatedlineedit.h>
#include <qt/receivecoinsdialog.h>
#include <qt/receiverequestdialog.h>
#include <qt/recentrequeststablemodel.h>
#include <qt/sendcoinsdialog.h>
#include <qt/sendcoinsentry.h>
#include <qt/transactiontablemodel.h>
#include <qt/transactionview.h>
#include <qt/walletmodel.h>
#include <script/solver.h>
#include <test/util/setup_common.h>
#include <validation.h>
#include <wallet/test/util.h>
#include <wallet/wallet.h>
#include <chrono>
#include <memory>
#include <QAbstractButton>
#include <QAction>
#include <QApplication>
#include <QCheckBox>
#include <QClipboard>
#include <QObject>
#include <QPushButton>
#include <QTimer>
#include <QVBoxLayout>
#include <QTextEdit>
#include <QListView>
#include <QDialogButtonBox>
using wallet::AddWallet;
using wallet::CWallet;
using wallet::CreateMockableWalletDatabase;
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<SendConfirmationDialog*>(widget);
if (text) *text = dialog->text();
QAbstractButton* button = dialog->button(confirm_type);
button->setEnabled(true);
button->click();
}
}
});
}
//! Send coins to address and return txid.
Txid SendCoins(CWallet& wallet, SendCoinsDialog& sendCoinsDialog, const CTxDestination& address, CAmount amount, bool rbf,
QMessageBox::StandardButton confirm_type = QMessageBox::Yes)
{
QVBoxLayout* entries = sendCoinsDialog.findChild<QVBoxLayout*>("entries");
SendCoinsEntry* entry = qobject_cast<SendCoinsEntry*>(entries->itemAt(0)->widget());
entry->findChild<QValidatedLineEdit*>("payTo")->setText(QString::fromStdString(EncodeDestination(address)));
entry->findChild<BitcoinAmountField*>("payAmount")->setValue(amount);
sendCoinsDialog.findChild<QFrame*>("frameFee")
->findChild<QFrame*>("frameFeeSelection")
->findChild<QCheckBox*>("optInRBF")
->setCheckState(rbf ? Qt::Checked : Qt::Unchecked);
Txid txid;
boost::signals2::scoped_connection c(wallet.NotifyTransactionChanged.connect([&txid](const Txid& 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 Txid& 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 Txid& txid, bool expectDisabled, std::string expectError, bool cancel)
{
QTableView* table = view.findChild<QTableView*>("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<QAction*>("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<QVBoxLayout*>("entries");
QVERIFY(entries->count() == 1); // only one entry
SendCoinsEntry* send_entry = qobject_cast<SendCoinsEntry*>(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<CWallet>& 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<CWallet> SetupDescriptorsWallet(interfaces::Node& node, TestChain100Setup& test, bool watch_only = false)
{
std::shared_ptr<CWallet> wallet = std::make_shared<CWallet>(node.context()->chain.get(), "", CreateMockableWalletDatabase());
wallet->LoadWallet();
LOCK(wallet->cs_wallet);
wallet->SetWalletFlag(WALLET_FLAG_DESCRIPTORS);
if (watch_only) {
wallet->SetWalletFlag(WALLET_FLAG_DISABLE_PRIVATE_KEYS);
} else {
wallet->SetupDescriptorScriptPubKeyMans();
}
// Add the coinbase key
FlatSigningProvider provider;
std::string error;
std::string key_str;
if (watch_only) {
key_str = HexStr(test.coinbaseKey.GetPubKey());
} else {
key_str = EncodeSecret(test.coinbaseKey);
}
auto descs = Parse("combo(" + key_str + ")", provider, error, /* require_checksum=*/ false);
assert(!descs.empty());
assert(descs.size() == 1);
auto& desc = descs.at(0);
WalletDescriptor w_desc(std::move(desc), 0, 0, 1, 1);
auto spk_manager = *Assert(wallet->AddWalletDescriptor(w_desc, provider, "", false));
assert(spk_manager);
CTxDestination dest = GetDestinationForKey(test.coinbaseKey.GetPubKey(), wallet->m_default_address_type);
wallet->SetAddressBook(dest, "", wallet::AddressPurpose::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> clientModel;
std::unique_ptr<WalletModel> 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<ClientModel>(node, &optionsModel);
}
void initModelForWallet(interfaces::Node& node, const std::shared_ptr<CWallet>& wallet, const PlatformStyle* platformStyle)
{
WalletContext& context = *node.walletLoader().context();
AddWallet(context, wallet);
walletModel = std::make_unique<WalletModel>(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 build/bin/test_bitcoin-qt # Linux
// QT_QPA_PLATFORM=windows build/bin/test_bitcoin-qt # Windows
// QT_QPA_PLATFORM=cocoa build/bin/test_bitcoin-qt # macOS
void TestGUI(interfaces::Node& node, const std::shared_ptr<CWallet>& wallet)
{
// Create widgets for sending coins and listing transactions.
std::unique_ptr<const PlatformStyle> 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<QLabel*>("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);
Txid txid1 = SendCoins(*wallet.get(), sendCoinsDialog, PKHash(), 5 * COIN, /*rbf=*/false);
Txid 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 canceled fullrbf bump, canceled bip-125-rbf bump, passing bump, and then failing bump.
BumpFee(transactionView, txid1, /*expectDisabled=*/false, /*expectError=*/{}, /*cancel=*/true);
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<QLabel*>("labelBalance"));
// Check Request Payment button
ReceiveCoinsDialog receiveCoinsDialog(platformStyle.get());
receiveCoinsDialog.setModel(&walletModel);
RecentRequestsTableModel* requestTableModel = walletModel.getRecentRequestsTableModel();
// Label input
QLineEdit* labelInput = receiveCoinsDialog.findChild<QLineEdit*>("reqLabel");
labelInput->setText("TEST_LABEL_1");
// Amount input
BitcoinAmountField* amountInput = receiveCoinsDialog.findChild<BitcoinAmountField*>("reqAmount");
amountInput->setValue(1);
// Message input
QLineEdit* messageInput = receiveCoinsDialog.findChild<QLineEdit*>("reqMessage");
messageInput->setText("TEST_MESSAGE_1");
int initialRowCount = requestTableModel->rowCount({});
QPushButton* requestPaymentButton = receiveCoinsDialog.findChild<QPushButton*>("receiveButton");
requestPaymentButton->click();
QString address;
for (QWidget* widget : QApplication::topLevelWidgets()) {
if (widget->inherits("ReceiveRequestDialog")) {
ReceiveRequestDialog* receiveRequestDialog = qobject_cast<ReceiveRequestDialog*>(widget);
QCOMPARE(receiveRequestDialog->QObject::findChild<QLabel*>("payment_header")->text(), QString("Payment information"));
QCOMPARE(receiveRequestDialog->QObject::findChild<QLabel*>("uri_tag")->text(), QString("URI:"));
QString uri = receiveRequestDialog->QObject::findChild<QLabel*>("uri_content")->text();
QCOMPARE(uri.count("bitcoin:"), 2);
QCOMPARE(receiveRequestDialog->QObject::findChild<QLabel*>("address_tag")->text(), QString("Address:"));
QVERIFY(address.isEmpty());
address = receiveRequestDialog->QObject::findChild<QLabel*>("address_content")->text();
QVERIFY(!address.isEmpty());
QCOMPARE(uri.count("amount=0.00000001"), 2);
QCOMPARE(receiveRequestDialog->QObject::findChild<QLabel*>("amount_tag")->text(), QString("Amount:"));
QCOMPARE(receiveRequestDialog->QObject::findChild<QLabel*>("amount_content")->text(), QString::fromStdString("0.00000001 " + CURRENCY_UNIT));
QCOMPARE(uri.count("label=TEST_LABEL_1"), 2);
QCOMPARE(receiveRequestDialog->QObject::findChild<QLabel*>("label_tag")->text(), QString("Label:"));
QCOMPARE(receiveRequestDialog->QObject::findChild<QLabel*>("label_content")->text(), QString("TEST_LABEL_1"));
QCOMPARE(uri.count("message=TEST_MESSAGE_1"), 2);
QCOMPARE(receiveRequestDialog->QObject::findChild<QLabel*>("message_tag")->text(), QString("Message:"));
QCOMPARE(receiveRequestDialog->QObject::findChild<QLabel*>("message_content")->text(), QString("TEST_MESSAGE_1"));
}
}
// Clear button
QPushButton* clearButton = receiveCoinsDialog.findChild<QPushButton*>("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<std::string> 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<QTableView*>("recentRequestsView");
table->selectRow(currentRowCount-1);
QPushButton* removeRequestButton = receiveCoinsDialog.findChild<QPushButton*>("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<CWallet>& wallet = SetupDescriptorsWallet(node, test, /*watch_only=*/true);
// Create widgets and init models
std::unique_ptr<const PlatformStyle> 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().balance,
sendCoinsDialog.findChild<QLabel*>("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") && widget->objectName().compare("psbt_copied_message") == 0) {
QMessageBox* dialog = qobject_cast<QMessageBox*>(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<std::vector<unsigned char>> 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<CWallet>& 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).
qWarning() << "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);
}