diff --git a/src/Makefile.bench.include b/src/Makefile.bench.include index c54ad6e6b2b6..d4ce2fb20ba7 100644 --- a/src/Makefile.bench.include +++ b/src/Makefile.bench.include @@ -92,6 +92,7 @@ if ENABLE_WALLET bench_bench_dash_SOURCES += bench/coin_selection.cpp bench_bench_dash_SOURCES += bench/wallet_balance.cpp bench_bench_dash_SOURCES += bench/wallet_create.cpp +bench_bench_dash_SOURCES += bench/wallet_create_tx.cpp bench_bench_dash_SOURCES += bench/wallet_loading.cpp bench_bench_dash_LDADD += $(BDB_LIBS) $(SQLITE_LIBS) endif diff --git a/src/bench/wallet_create_tx.cpp b/src/bench/wallet_create_tx.cpp new file mode 100644 index 000000000000..59e6d99bfa34 --- /dev/null +++ b/src/bench/wallet_create_tx.cpp @@ -0,0 +1,142 @@ +// Copyright (c) 2022 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://www.opensource.org/licenses/mit-license.php. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using wallet::CWallet; +using wallet::CreateMockWalletDatabase; +using wallet::DBErrors; +using wallet::WALLET_FLAG_DESCRIPTORS; + +struct TipBlock +{ + uint256 prev_block_hash; + int64_t prev_block_time; + int tip_height; +}; + +TipBlock getTip(const CChainParams& params, const node::NodeContext& context) +{ + auto tip = WITH_LOCK(::cs_main, return context.chainman->ActiveTip()); + return (tip) ? TipBlock{tip->GetBlockHash(), tip->GetBlockTime(), tip->nHeight} : + TipBlock{params.GenesisBlock().GetHash(), params.GenesisBlock().GetBlockTime(), 0}; +} + +void generateFakeBlock(const CChainParams& params, + const node::NodeContext& context, + CWallet& wallet, + const CScript& coinbase_out_script) +{ + TipBlock tip{getTip(params, context)}; + + // Create block + CBlock block; + CMutableTransaction coinbase_tx; + coinbase_tx.vin.resize(1); + coinbase_tx.vin[0].prevout.SetNull(); + coinbase_tx.vout.resize(2); + coinbase_tx.vout[0].scriptPubKey = coinbase_out_script; + coinbase_tx.vout[0].nValue = 49 * COIN; + coinbase_tx.vin[0].scriptSig = CScript() << ++tip.tip_height << OP_0; + coinbase_tx.vout[1].scriptPubKey = coinbase_out_script; // extra output + coinbase_tx.vout[1].nValue = 1 * COIN; + block.vtx = {MakeTransactionRef(std::move(coinbase_tx))}; + + block.nVersion = VERSIONBITS_LAST_OLD_BLOCK_VERSION; + block.hashPrevBlock = tip.prev_block_hash; + block.hashMerkleRoot = BlockMerkleRoot(block); + block.nTime = ++tip.prev_block_time; + block.nBits = params.GenesisBlock().nBits; + block.nNonce = 0; + + { + LOCK(::cs_main); + // Add it to the index + CBlockIndex* pindex{context.chainman->m_blockman.AddToBlockIndex(block, block.GetHash(), context.chainman->m_best_header)}; + // add it to the chain + context.chainman->ActiveChain().SetTip(*pindex); + } + + // notify wallet + const auto& pindex = WITH_LOCK(::cs_main, return context.chainman->ActiveChain().Tip()); + wallet.blockConnected(kernel::MakeBlockInfo(pindex, &block)); +} + +struct PreSelectInputs { + // How many coins from the wallet the process should select + int num_of_internal_inputs; + // future: this could have external inputs as well. +}; + +static void WalletCreateTx(benchmark::Bench& bench, bool allow_other_inputs, std::optional preset_inputs) +{ + const auto test_setup = MakeNoLogFileContext(); + + CWallet wallet{test_setup->m_node.chain.get(), test_setup->m_node.coinjoin_loader.get(), "", gArgs, CreateMockWalletDatabase()}; + { + LOCK(wallet.cs_wallet); + wallet.SetWalletFlag(WALLET_FLAG_DESCRIPTORS); + wallet.SetupDescriptorScriptPubKeyMans("", ""); + if (wallet.LoadWallet() != DBErrors::LOAD_OK) assert(false); + } + + // Generate destinations + CScript dest = GetScriptForDestination(getNewDestination(wallet)); + + // Generate chain; each coinbase will have two outputs to fill-up the wallet + const auto& params = Params(); + unsigned int chain_size = 5000; // 5k blocks means 10k UTXO for the wallet (minus 100 due COINBASE_MATURITY) + for (unsigned int i = 0; i < chain_size; ++i) { + generateFakeBlock(params, test_setup->m_node, wallet, dest); + } + + // Check available balance + auto bal = WITH_LOCK(wallet.cs_wallet, return wallet::AvailableCoins(wallet).total_amount); // Cache + assert(bal == 50 * COIN * (chain_size - COINBASE_MATURITY)); + + wallet::CCoinControl coin_control; + coin_control.m_allow_other_inputs = allow_other_inputs; + + CAmount target = 0; + if (preset_inputs) { + // Select inputs, each has 49 DASH + const auto& res = WITH_LOCK(wallet.cs_wallet, + return wallet::AvailableCoins(wallet, nullptr, std::nullopt, 1, MAX_MONEY, + MAX_MONEY, preset_inputs->num_of_internal_inputs)); + for (int i = 0; i < preset_inputs->num_of_internal_inputs; i++) { + const auto& coin{res.legacy[i]}; + target += coin.txout.nValue; + coin_control.Select(coin.outpoint); + } + } + + // If automatic coin selection is enabled, add the value of another UTXO to the target + if (coin_control.m_allow_other_inputs) target += 50 * COIN; + std::vector recipients = {{dest, target, true}}; + + bench.epochIterations(5).run([&] { + LOCK(wallet.cs_wallet); + const auto& tx_res = CreateTransaction(wallet, recipients, wallet::RANDOM_CHANGE_POSITION, coin_control); + assert(tx_res); + }); +} + +static void WalletCreateTxUseOnlyPresetInputs(benchmark::Bench& bench) { WalletCreateTx(bench, /*allow_other_inputs=*/false, + {{/*num_of_internal_inputs=*/4}}); } + +static void WalletCreateTxUsePresetInputsAndCoinSelection(benchmark::Bench& bench) { WalletCreateTx(bench, /*allow_other_inputs=*/true, + {{/*num_of_internal_inputs=*/4}}); } + +BENCHMARK(WalletCreateTxUseOnlyPresetInputs, benchmark::PriorityLevel::LOW) +BENCHMARK(WalletCreateTxUsePresetInputsAndCoinSelection, benchmark::PriorityLevel::LOW) diff --git a/src/qt/sendcoinsdialog.cpp b/src/qt/sendcoinsdialog.cpp index 1b25c171abb5..7864efb6f834 100644 --- a/src/qt/sendcoinsdialog.cpp +++ b/src/qt/sendcoinsdialog.cpp @@ -324,7 +324,9 @@ bool SendCoinsDialog::send(const QList& recipients, QString& updateCoinControlState(); - prepareStatus = model->prepareTransaction(*m_current_transaction, *m_coin_control); + CCoinControl coin_control = *m_coin_control; + coin_control.m_allow_other_inputs = !coin_control.HasSelected(); // future, could introduce a checkbox to customize this value. + prepareStatus = model->prepareTransaction(*m_current_transaction, coin_control); // process prepareStatus and on error generate message shown to user processSendCoinsReturn(prepareStatus, @@ -505,7 +507,7 @@ void SendCoinsDialog::presentPSBT(PartiallySignedTransaction& psbtx) CDataStream ssTx(SER_NETWORK, PROTOCOL_VERSION); ssTx << psbtx; GUIUtil::setClipboard(EncodeBase64(ssTx.str()).c_str()); - QMessageBox msgBox; + QMessageBox msgBox(this); msgBox.setText("Unsigned Transaction"); msgBox.setInformativeText("The PSBT has been copied to the clipboard. You can also save it."); msgBox.setStandardButtons(QMessageBox::Save | QMessageBox::Discard); @@ -895,8 +897,13 @@ void SendCoinsDialog::useAvailableBalance(SendCoinsEntry* entry) // Include watch-only for wallets without private key m_coin_control->fAllowWatchOnly = model->wallet().privateKeysDisabled() && !model->wallet().hasExternalSigner(); + // Same behavior as send: if we have selected coins, only obtain their available balance. + // Copy to avoid modifying the member's data. + CCoinControl coin_control = *m_coin_control; + coin_control.m_allow_other_inputs = !coin_control.HasSelected(); + // Calculate available amount to send. - CAmount amount = model->wallet().getAvailableBalance(*m_coin_control); + CAmount amount = model->wallet().getAvailableBalance(coin_control); for (int i = 0; i < ui->entries->count(); ++i) { SendCoinsEntry* e = qobject_cast(ui->entries->itemAt(i)->widget()); if (e && !e->isHidden() && e != entry) { diff --git a/src/qt/sendcoinsdialog.h b/src/qt/sendcoinsdialog.h index d691f1555195..0eb1cc877818 100644 --- a/src/qt/sendcoinsdialog.h +++ b/src/qt/sendcoinsdialog.h @@ -50,6 +50,9 @@ class SendCoinsDialog : public QDialog void pasteEntry(const SendCoinsRecipient &rv); bool handlePaymentRequest(const SendCoinsRecipient &recipient); + // Only used for testing-purposes + wallet::CCoinControl* getCoinControl() { return m_coin_control.get(); } + public Q_SLOTS: void clear(); void reject() override; diff --git a/src/qt/test/wallettests.cpp b/src/qt/test/wallettests.cpp index 1394ac1fdea1..5fd23b4766c3 100644 --- a/src/qt/test/wallettests.cpp +++ b/src/qt/test/wallettests.cpp @@ -18,9 +18,13 @@ #include #include #include +#include +#include #include +#include #include #include +#include #include #include #include @@ -34,8 +38,11 @@ #include #include #include +#include #include +#include #include +#include #include #include #include @@ -47,21 +54,22 @@ 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, bool cancel = false) +//! Press "Yes", "Save", or "Cancel" buttons in modal send confirmation dialog. +void ConfirmSend(QString* text = nullptr, QMessageBox::StandardButton confirm_type = QMessageBox::Yes) { - QTimer::singleShot(0, [text, cancel]() { + 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(cancel ? QMessageBox::Cancel : QMessageBox::Yes); + QAbstractButton* button = dialog->button(confirm_type); button->setEnabled(true); button->click(); } @@ -70,7 +78,8 @@ void ConfirmSend(QString* text = nullptr, bool cancel = false) } //! Send coins to address and return txid. -uint256 SendCoins(CWallet& wallet, SendCoinsDialog& sendCoinsDialog, const CTxDestination& address, CAmount amount) +uint256 SendCoins(CWallet& wallet, SendCoinsDialog& sendCoinsDialog, const CTxDestination& address, CAmount amount, + QMessageBox::StandardButton confirm_type = QMessageBox::Yes) { QVBoxLayout* entries = sendCoinsDialog.findChild("entries"); SendCoinsEntry* entry = qobject_cast(entries->itemAt(0)->widget()); @@ -80,7 +89,7 @@ uint256 SendCoins(CWallet& wallet, SendCoinsDialog& sendCoinsDialog, const CTxDe boost::signals2::scoped_connection c(wallet.NotifyTransactionChanged.connect([&txid](const uint256& hash, ChangeType status) { if (status == CT_NEW) txid = hash; })); - ConfirmSend(); + ConfirmSend(/*text=*/nullptr, confirm_type); bool invoked = QMetaObject::invokeMethod(&sendCoinsDialog, "sendButtonClicked", Q_ARG(bool, false)); assert(invoked); return txid; @@ -100,6 +109,71 @@ QModelIndex FindTx(const QAbstractItemModel& model, const uint256& txid) return {}; } +// 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.wallet().getBalance()); + + // 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) +{ + // Dash wallets require the CoinJoin loader; pass the test node loader through. + std::shared_ptr wallet = std::make_shared(node.context()->chain.get(), node.context()->coinjoin_loader.get(), "", gArgs, 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; +} + //! Simple qt wallet tests. // // Test widgets can be debugged interactively calling show() on them and @@ -141,14 +215,7 @@ void TestGUI(interfaces::Node& node) wallet->SetAddressBook(dest, "", "receive"); wallet->SetLastBlockProcessed(105, WITH_LOCK(node.context()->chainman->GetMutex(), return node.context()->chainman->ActiveChain().Tip()->GetBlockHash())); } - { - 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()); - } + SyncUpWallet(wallet, node); wallet->SetBroadcastTransactions(true); // Create widgets for sending coins and listing transactions. @@ -172,6 +239,9 @@ void TestGUI(interfaces::Node& node) QCOMPARE(balanceText, balanceComparison); } + // 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); @@ -302,6 +372,79 @@ void TestGUI(interfaces::Node& node) QCOMPARE(walletModel.wallet().getAddressReceiveRequests().size(), size_t{0}); } +//! Verify PSBT creation on a legacy watch-only wallet (Create Unsigned path). +// +// Dash does not use WalletModel::getAvailableBalance with a cached-balance path +// (bitcoin-core/gui#598 / bitcoin#26687). Watch-only coins are spendable for +// selection through wallet().getAvailableBalance / AvailableCoins when +// fAllowWatchOnly is set in updateCoinControlState / useAvailableBalance. +// This test covers the user-visible watch-only PSBT flow that remains relevant +// in Dash: private-keys-disabled legacy wallet, watch-only balance display, and +// "Create Unsigned" producing a clipboard PSBT. +void TestGUIWatchOnly(interfaces::Node& node) +{ + TestChain100Setup test; + for (int i = 0; i < 5; ++i) { + test.CreateAndProcessBlock({}, GetScriptForRawPubKey(test.coinbaseKey.GetPubKey())); + } + node.setContext(&test.m_node); + WalletContext& context = *node.walletLoader().context(); + + const std::shared_ptr wallet = SetupLegacyWatchOnlyWallet(node, test); + AddWallet(context, wallet); + + SendCoinsDialog sendCoinsDialog; + OptionsModel optionsModel(node); + bilingual_str error; + QVERIFY(optionsModel.Init(error)); + ClientModel clientModel(node, &optionsModel); + WalletModel walletModel(interfaces::MakeWallet(context, wallet), clientModel); + sendCoinsDialog.setModel(&walletModel); + + // Update cached balance and check the send dialog shows the watch-only balance. + walletModel.pollBalanceChanged(); + { + QLabel* balanceLabel = sendCoinsDialog.findChild("labelBalance"); + BitcoinUnit unit = walletModel.getOptionsModel()->getDisplayUnit(); + CAmount watch_only_balance = walletModel.wallet().getBalances().watch_only_balance; + QVERIFY(watch_only_balance > 0); + QString balanceComparison = BitcoinUnits::formatWithUnit(unit, watch_only_balance, false); + QCOMPARE(balanceLabel->text(), balanceComparison); + } + + // Set change address so createTransaction can complete without a keypool change key. + sendCoinsDialog.getCoinControl()->destChange = GetDestinationForKey(test.coinbaseKey.GetPubKey(), OutputType::LEGACY); + + // Dismiss the "PSBT has been copied" dialog that presentPSBT opens after Save. + 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); + if (!button) continue; + button->setEnabled(true); + button->click(); + timer.stop(); + break; + } + } + }); + timer.start(500); + + // Create unsigned PSBT and verify it was copied to the clipboard. + SendCoins(*wallet.get(), sendCoinsDialog, PKHash(), 5 * COIN, QMessageBox::Save); + const std::string& psbt_string = QApplication::clipboard()->text().toStdString(); + QVERIFY(!psbt_string.empty()); + + PartiallySignedTransaction psbt; + std::string err; + QVERIFY(DecodeBase64PSBT(psbt, psbt_string, err)); + + RemoveWallet(context, wallet, /*load_on_start=*/std::nullopt); +} + } // namespace void WalletTests::walletTests() @@ -318,4 +461,5 @@ void WalletTests::walletTests() } #endif TestGUI(m_node); + TestGUIWatchOnly(m_node); } diff --git a/src/rpc/evo.cpp b/src/rpc/evo.cpp index 308cbe0ce267..0b81ef3067d4 100644 --- a/src/rpc/evo.cpp +++ b/src/rpc/evo.cpp @@ -372,6 +372,8 @@ static void FundSpecialTx(CWallet& wallet, CMutableTransaction& tx, const Specia CCoinControl coinControl; coinControl.destChange = fundDest; + // Fund from the given address only, spending no more of its coins than necessary. + coinControl.m_allow_other_inputs = false; coinControl.fRequireAllInputs = false; for (const auto& out : AvailableCoinsListUnspent(wallet).all()) { diff --git a/src/test/util/wallet.cpp b/src/test/util/wallet.cpp index e1d59b3bc76e..d610fe314428 100644 --- a/src/test/util/wallet.cpp +++ b/src/test/util/wallet.cpp @@ -25,7 +25,12 @@ const std::string ADDRESS_BCRT1_UNSPENDABLE = "bcrt1qqqqqqqqqqqqqqqqqqqqqqqqqqqq #ifdef ENABLE_WALLET std::string getnewaddress(CWallet& w) { - return EncodeDestination(*Assert(w.GetNewDestination(""))); + return EncodeDestination(getNewDestination(w)); +} + +CTxDestination getNewDestination(CWallet& w) +{ + return *Assert(w.GetNewDestination("")); } // void importaddress(CWallet& wallet, const std::string& address) diff --git a/src/test/util/wallet.h b/src/test/util/wallet.h index d8a81c2d8224..9a96f663daec 100644 --- a/src/test/util/wallet.h +++ b/src/test/util/wallet.h @@ -5,6 +5,8 @@ #ifndef BITCOIN_TEST_UTIL_WALLET_H #define BITCOIN_TEST_UTIL_WALLET_H +#include