From f4ff2b8db93cea7ce16c1b914bd4a89393a5810c Mon Sep 17 00:00:00 2001 From: pseudoramdom Date: Fri, 24 Apr 2026 13:00:47 -0700 Subject: [PATCH 1/4] qml: Implement Replace-by-fee --- qml/bitcoin.cpp | 3 + qml/bitcoin_qml.qrc | 2 + qml/components/InfoBanner.qml | 151 ++++++++++++++++++++++ qml/models/activitylistmodel.cpp | 43 +++++++ qml/models/activitylistmodel.h | 8 +- qml/models/bumptransactionmodel.cpp | 152 ++++++++++++++++++++++ qml/models/bumptransactionmodel.h | 79 ++++++++++++ qml/models/transaction.cpp | 20 +++ qml/models/transaction.h | 3 + qml/models/walletqmlmodel.cpp | 10 ++ qml/models/walletqmlmodel.h | 6 + qml/pages/main.qml | 19 +-- qml/pages/wallet/Activity.qml | 21 +++- qml/pages/wallet/ActivityDetails.qml | 70 +++++++++-- qml/pages/wallet/SendResult.qml | 71 ++++++----- qml/pages/wallet/SpeedUpOverlay.qml | 181 +++++++++++++++++++++++++++ 16 files changed, 793 insertions(+), 46 deletions(-) create mode 100644 qml/components/InfoBanner.qml create mode 100644 qml/models/bumptransactionmodel.cpp create mode 100644 qml/models/bumptransactionmodel.h create mode 100644 qml/pages/wallet/SpeedUpOverlay.qml diff --git a/qml/bitcoin.cpp b/qml/bitcoin.cpp index 0828746a7e..7607f59148 100644 --- a/qml/bitcoin.cpp +++ b/qml/bitcoin.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -364,6 +365,8 @@ int QmlGuiMain(int argc, char* argv[]) qmlRegisterUncreatableType("org.bitcoincore.qt", 1, 0, "SendRecipient", ""); #ifdef ENABLE_WALLET + qmlRegisterUncreatableType("org.bitcoincore.qt", 1, 0, "BumpTransactionModel", + "BumpTransactionModel cannot be instantiated from QML"); qmlRegisterUncreatableType("org.bitcoincore.qt", 1, 0, "WalletQmlModel", "WalletQmlModel cannot be instantiated from QML"); qmlRegisterUncreatableType("org.bitcoincore.qt", 1, 0, "WalletQmlModelTransaction", diff --git a/qml/bitcoin_qml.qrc b/qml/bitcoin_qml.qrc index a6322559a3..43b2b0bb9f 100644 --- a/qml/bitcoin_qml.qrc +++ b/qml/bitcoin_qml.qrc @@ -12,6 +12,7 @@ components/ExternalPopup.qml components/FeeSelection.qml components/MonospaceOutputView.qml + components/InfoBanner.qml components/NetworkTrafficGraph.qml components/NetworkIndicator.qml components/OptionPopup.qml @@ -103,6 +104,7 @@ pages/wallet/Send.qml pages/wallet/SendResult.qml pages/wallet/SendReview.qml + pages/wallet/SpeedUpOverlay.qml pages/wallet/WalletBadge.qml pages/wallet/WalletSelect.qml diff --git a/qml/components/InfoBanner.qml b/qml/components/InfoBanner.qml new file mode 100644 index 0000000000..1feb4c1154 --- /dev/null +++ b/qml/components/InfoBanner.qml @@ -0,0 +1,151 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import org.bitcoincore.qt 1.0 + +import "../controls" + +Rectangle { + id: root + + enum Layout { + Horizontal, + Vertical + } + + property url iconSource: "" + property string title: "" + property string message: "" + property string primaryButtonText: "" + property string dismissButtonText: "" + property int bannerLayout: InfoBanner.Layout.Horizontal + + signal primaryClicked() + signal dismissClicked() + + radius: 10 + color: Qt.rgba(Theme.color.blue.r, Theme.color.blue.g, Theme.color.blue.b, 0.3) + implicitHeight: contentLoader.item ? contentLoader.item.height + 60 : 60 + + Loader { + id: contentLoader + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: 30 + sourceComponent: root.bannerLayout === InfoBanner.Layout.Vertical + ? verticalContent : horizontalContent + } + + Component { + id: horizontalContent + RowLayout { + spacing: 15 + + Icon { + visible: root.iconSource != "" + source: root.iconSource + color: Theme.color.neutral7 + size: 24 + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + CoreText { + visible: root.title !== "" + text: root.title + font.pixelSize: 15 + bold: true + color: Theme.color.neutral9 + horizontalAlignment: Text.AlignLeft + Layout.fillWidth: true + } + + CoreText { + visible: root.message !== "" + text: root.message + font.pixelSize: 13 + color: Theme.color.neutral7 + horizontalAlignment: Text.AlignLeft + Layout.fillWidth: true + wrap: true + } + } + + OutlineButton { + visible: root.dismissButtonText !== "" + text: root.dismissButtonText + onClicked: root.dismissClicked() + } + + ContinueButton { + visible: root.primaryButtonText !== "" + text: root.primaryButtonText + onClicked: root.primaryClicked() + } + } + } + + Component { + id: verticalContent + ColumnLayout { + spacing: 15 + + Icon { + visible: root.iconSource != "" + source: root.iconSource + color: Theme.color.neutral7 + size: 24 + Layout.alignment: Qt.AlignHCenter + } + + CoreText { + visible: root.title !== "" + text: root.title + font.pixelSize: 15 + bold: true + color: Theme.color.neutral9 + horizontalAlignment: Text.AlignHCenter + Layout.fillWidth: true + } + + CoreText { + visible: root.message !== "" + text: root.message + font.pixelSize: 13 + color: Theme.color.neutral7 + horizontalAlignment: Text.AlignHCenter + Layout.fillWidth: true + Layout.bottomMargin: 10 + wrap: true + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 15 + + OutlineButton { + visible: root.dismissButtonText !== "" + text: root.dismissButtonText + Layout.maximumWidth: 200 + onClicked: root.dismissClicked() + } + + ContinueButton { + visible: root.primaryButtonText !== "" + text: root.primaryButtonText + Layout.maximumWidth: 200 + leftPadding: 30 + rightPadding: 30 + onClicked: root.primaryClicked() + } + } + } + } +} diff --git a/qml/models/activitylistmodel.cpp b/qml/models/activitylistmodel.cpp index 8fb57eca56..fd91c05ef7 100644 --- a/qml/models/activitylistmodel.cpp +++ b/qml/models/activitylistmodel.cpp @@ -75,6 +75,14 @@ QVariant ActivityListModel::data(const QModelIndex &index, int role) const return tx->status; case TypeRole: return tx->type; + case TxidRole: + return tx->txid; + case CanBumpRole: + return m_wallet_model ? m_wallet_model->canBumpTransaction(tx->hash) : false; + case ReplacesTxidRole: + return tx->replacesTxid; + case ReplacedByTxidRole: + return tx->replacedByTxid; default: return QVariant(); } @@ -90,9 +98,44 @@ QHash ActivityListModel::roleNames() const roles[LabelRole] = "label"; roles[StatusRole] = "status"; roles[TypeRole] = "type"; + roles[TxidRole] = "txid"; + roles[CanBumpRole] = "canBump"; + roles[ReplacesTxidRole] = "replacesTxid"; + roles[ReplacedByTxidRole] = "replacedByTxid"; return roles; } +void ActivityListModel::reload() +{ + beginResetModel(); + m_transactions.clear(); + refreshWallet(); + endResetModel(); +} + +QVariantMap ActivityListModel::transactionDetails(const QString& txid) const +{ + for (const auto& tx : m_transactions) { + if (tx->txid == txid) { + updateTransactionStatus(tx); + updateTransactionLabel(tx); + return { + {"txid", tx->txid}, + {"canBump", m_wallet_model ? m_wallet_model->canBumpTransaction(tx->hash) : false}, + {"replacedByTxid", tx->replacedByTxid}, + {"amount", tx->prettyAmount()}, + {"date", tx->dateTimeString()}, + {"depth", tx->depth}, + {"type", tx->type}, + {"status", tx->status}, + {"address", tx->address}, + {"label", tx->label} + }; + } + } + return {}; +} + void ActivityListModel::refreshWallet() { if (m_wallet_model == nullptr) { diff --git a/qml/models/activitylistmodel.h b/qml/models/activitylistmodel.h index 9467167055..3749c59294 100644 --- a/qml/models/activitylistmodel.h +++ b/qml/models/activitylistmodel.h @@ -32,9 +32,15 @@ class ActivityListModel : public QAbstractListModel DepthRole, LabelRole, StatusRole, - TypeRole + TypeRole, + TxidRole, + CanBumpRole, + ReplacesTxidRole, + ReplacedByTxidRole }; + Q_INVOKABLE void reload(); + Q_INVOKABLE QVariantMap transactionDetails(const QString& txid) const; int rowCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; QHash roleNames() const override; diff --git a/qml/models/bumptransactionmodel.cpp b/qml/models/bumptransactionmodel.cpp new file mode 100644 index 0000000000..eadd3097b0 --- /dev/null +++ b/qml/models/bumptransactionmodel.cpp @@ -0,0 +1,152 @@ +// Copyright (c) 2026 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 + +namespace { +QString FormatFee(CAmount amount) +{ + BitcoinAmount bitcoin_amount; + bitcoin_amount.setSatoshi(amount); + return bitcoin_amount.toDisplay() + QStringLiteral(" ") + bitcoin_amount.unitLabel(); +} +} // namespace + +BumpTransactionModel::BumpTransactionModel(interfaces::Wallet* wallet, QObject* parent) + : QObject(parent) + , m_wallet(wallet) +{ +} + +QString BumpTransactionModel::oldFee() const +{ + return FormatFee(m_old_fee); +} + +QString BumpTransactionModel::newFee() const +{ + return FormatFee(m_new_fee); +} + +QString BumpTransactionModel::feeIncrease() const +{ + return FormatFee(m_new_fee - m_old_fee); +} + +QString BumpTransactionModel::oldTxid() const +{ + return QString::fromStdString(m_original_txid.GetHex()); +} + +void BumpTransactionModel::setState(State state) +{ + if (m_state != state) { + m_state = state; + Q_EMIT stateChanged(); + } +} + +void BumpTransactionModel::setActionType(ActionType type) +{ + if (m_action_type != type) { + m_action_type = type; + Q_EMIT actionTypeChanged(); + } +} + +void BumpTransactionModel::setError(const QString& error) +{ + m_error = error; + setState(Failed); + Q_EMIT resultChanged(); +} + +void BumpTransactionModel::prepareFeeBump(const QString& txid, unsigned int targetBlocks) +{ + if (!m_wallet) { + setError(tr("No wallet available.")); + return; + } + + auto parsed = uint256::FromHex(txid.toStdString()); + if (!parsed) { + setError(tr("Invalid transaction ID.")); + return; + } + + setActionType(SpeedUp); + setState(Preparing); + + wallet::CCoinControl coin_control; + coin_control.m_signal_bip125_rbf = true; + coin_control.m_confirm_target = targetBlocks; + + std::vector errors; + CAmount old_fee{0}; + CAmount new_fee{0}; + CMutableTransaction mtx; + + if (!m_wallet->createBumpTransaction(Txid::FromUint256(*parsed), coin_control, errors, old_fee, new_fee, mtx)) { + setError(errors.empty() ? tr("Failed to create bump transaction.") : QString::fromStdString(errors[0].translated)); + return; + } + + m_original_txid = Txid::FromUint256(*parsed); + m_old_fee = old_fee; + m_new_fee = new_fee; + m_bump_mtx = std::move(mtx); + + setState(NeedsConfirmation); + Q_EMIT resultChanged(); +} + +void BumpTransactionModel::confirmFeeBump() +{ + if (!m_wallet || m_state != NeedsConfirmation) { + return; + } + + setState(Committing); + + if (!m_wallet->transactionCanBeBumped(m_original_txid)) { + setError(tr("Transaction can no longer be bumped.")); + return; + } + + if (!m_wallet->signBumpTransaction(m_bump_mtx)) { + setError(tr("Failed to sign transaction.")); + return; + } + + std::vector errors; + Txid bumped_txid; + if (!m_wallet->commitBumpTransaction(m_original_txid, std::move(m_bump_mtx), errors, bumped_txid)) { + setError(errors.empty() ? tr("Failed to commit transaction.") : QString::fromStdString(errors[0].translated)); + return; + } + + m_new_txid = QString::fromStdString(bumped_txid.GetHex()); + setState(Succeeded); + Q_EMIT resultChanged(); +} + +void BumpTransactionModel::reset() +{ + m_state = Idle; + m_action_type = SpeedUp; + m_old_fee = 0; + m_new_fee = 0; + m_bump_mtx = CMutableTransaction{}; + m_original_txid = Txid{}; + m_new_txid.clear(); + m_error.clear(); + + Q_EMIT stateChanged(); + Q_EMIT actionTypeChanged(); + Q_EMIT resultChanged(); +} diff --git a/qml/models/bumptransactionmodel.h b/qml/models/bumptransactionmodel.h new file mode 100644 index 0000000000..28de678468 --- /dev/null +++ b/qml/models/bumptransactionmodel.h @@ -0,0 +1,79 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QML_MODELS_BUMPTRANSACTIONMODEL_H +#define BITCOIN_QML_MODELS_BUMPTRANSACTIONMODEL_H + +#include +#include +#include + +#include + +#include +#include + +namespace interfaces { +class Wallet; +} // namespace interfaces + +struct bilingual_str; + +class BumpTransactionModel : public QObject +{ + Q_OBJECT + +public: + enum State { Idle, Preparing, NeedsConfirmation, Committing, Succeeded, Failed }; + Q_ENUM(State) + + enum ActionType { SpeedUp /*, Cancel */ }; + Q_ENUM(ActionType) + + Q_PROPERTY(State state READ state NOTIFY stateChanged) + Q_PROPERTY(ActionType actionType READ actionType NOTIFY actionTypeChanged) + Q_PROPERTY(QString oldFee READ oldFee NOTIFY resultChanged) + Q_PROPERTY(QString newFee READ newFee NOTIFY resultChanged) + Q_PROPERTY(QString feeIncrease READ feeIncrease NOTIFY resultChanged) + Q_PROPERTY(QString oldTxid READ oldTxid NOTIFY resultChanged) + Q_PROPERTY(QString newTxid READ newTxid NOTIFY resultChanged) + Q_PROPERTY(QString errorText READ errorText NOTIFY resultChanged) + + explicit BumpTransactionModel(interfaces::Wallet* wallet, QObject* parent = nullptr); + + State state() const { return m_state; } + ActionType actionType() const { return m_action_type; } + QString oldFee() const; + QString newFee() const; + QString feeIncrease() const; + QString oldTxid() const; + QString newTxid() const { return m_new_txid; } + QString errorText() const { return m_error; } + + Q_INVOKABLE void prepareFeeBump(const QString& txid, unsigned int targetBlocks); + Q_INVOKABLE void confirmFeeBump(); + Q_INVOKABLE void reset(); + +Q_SIGNALS: + void stateChanged(); + void actionTypeChanged(); + void resultChanged(); + +private: + void setState(State state); + void setActionType(ActionType type); + void setError(const QString& error); + + interfaces::Wallet* m_wallet{nullptr}; + State m_state{Idle}; + ActionType m_action_type{SpeedUp}; + CAmount m_old_fee{0}; + CAmount m_new_fee{0}; + CMutableTransaction m_bump_mtx; + Txid m_original_txid; + QString m_new_txid; + QString m_error; +}; + +#endif // BITCOIN_QML_MODELS_BUMPTRANSACTIONMODEL_H diff --git a/qml/models/transaction.cpp b/qml/models/transaction.cpp index f9a6de3523..a06b738789 100644 --- a/qml/models/transaction.cpp +++ b/qml/models/transaction.cpp @@ -139,8 +139,18 @@ QList> Transaction::fromWalletTx(const interfaces::W CAmount nDebit = wtx.debit; CAmount nNet = nCredit - nDebit; uint256 hash = wtx.tx->GetHash(); + QString txidStr = QString::fromStdString(hash.GetHex()); std::map mapValue = wtx.value_map; + QString replacesTxid; + QString replacedByTxid; + if (mapValue.count("replaces_txid")) { + replacesTxid = QString::fromStdString(mapValue["replaces_txid"]); + } + if (mapValue.count("replaced_by_txid")) { + replacedByTxid = QString::fromStdString(mapValue["replaced_by_txid"]); + } + bool involvesWatchAddress = false; isminetype fAllFromMe = ISMINE_SPENDABLE; bool any_from_me = false; @@ -175,6 +185,9 @@ QList> Transaction::fromWalletTx(const interfaces::W QSharedPointer sub = QSharedPointer::create(hash, nTime); sub->idx = i; + sub->txid = txidStr; + sub->replacesTxid = replacesTxid; + sub->replacedByTxid = replacedByTxid; sub->involvesWatchAddress = involvesWatchAddress; if (!std::get_if(&wtx.txout_address[i])) @@ -194,6 +207,7 @@ QList> Transaction::fromWalletTx(const interfaces::W /* Add fee to first output */ if (nTxFee > 0) { + sub->fee = nTxFee; nValue += nTxFee; nTxFee = 0; } @@ -211,6 +225,9 @@ QList> Transaction::fromWalletTx(const interfaces::W QSharedPointer sub = QSharedPointer::create(hash, nTime); sub->idx = i; // vout index + sub->txid = txidStr; + sub->replacesTxid = replacesTxid; + sub->replacedByTxid = replacedByTxid; sub->credit = txout.nValue; sub->involvesWatchAddress = false; if (wtx.txout_address_is_mine[i]) @@ -239,6 +256,9 @@ QList> Transaction::fromWalletTx(const interfaces::W // Mixed debit transaction, can't break down payees // parts.append(QSharedPointer::create(hash, nTime, Transaction::Other, "", nNet, 0)); + parts.last()->txid = txidStr; + parts.last()->replacesTxid = replacesTxid; + parts.last()->replacedByTxid = replacedByTxid; parts.last()->involvesWatchAddress = involvesWatchAddress; } diff --git a/qml/models/transaction.h b/qml/models/transaction.h index aaf7a08e44..783d1f4bef 100644 --- a/qml/models/transaction.h +++ b/qml/models/transaction.h @@ -64,6 +64,9 @@ class Transaction : public QObject QString timestamp; Type type; QString txid; + CAmount fee{0}; + QString replacesTxid; + QString replacedByTxid; bool countsForBalance; bool involvesWatchAddress; diff --git a/qml/models/walletqmlmodel.cpp b/qml/models/walletqmlmodel.cpp index cc621bb24f..d3206a2f89 100644 --- a/qml/models/walletqmlmodel.cpp +++ b/qml/models/walletqmlmodel.cpp @@ -251,6 +251,7 @@ WalletQmlModel::WalletQmlModel(std::unique_ptr wallet, QObje { m_wallet = std::move(wallet); m_activity_list_model = new ActivityListModel(this); + m_bump_transaction_model = new BumpTransactionModel(m_wallet.get(), this); m_coins_list_model = new CoinsListModel(this); m_send_recipients = new SendRecipientsListModel(this); m_current_payment_request = new PaymentRequest(this); @@ -261,6 +262,7 @@ WalletQmlModel::WalletQmlModel(QObject* parent) : QObject(parent) { m_activity_list_model = new ActivityListModel(this); + m_bump_transaction_model = new BumpTransactionModel(nullptr, this); m_coins_list_model = new CoinsListModel(this); m_send_recipients = new SendRecipientsListModel(this); m_current_payment_request = new PaymentRequest(this); @@ -719,6 +721,14 @@ void WalletQmlModel::sendTransaction() m_wallet->commitTransaction(newTx, value_map, order_form); } +bool WalletQmlModel::canBumpTransaction(const uint256& txid) const +{ + if (!m_wallet) { + return false; + } + return m_wallet->transactionCanBeBumped(Txid::FromUint256(txid)); +} + interfaces::Wallet::CoinsList WalletQmlModel::listCoins() const { if (!m_wallet) { diff --git a/qml/models/walletqmlmodel.h b/qml/models/walletqmlmodel.h index f31fe4b79f..919b602d09 100644 --- a/qml/models/walletqmlmodel.h +++ b/qml/models/walletqmlmodel.h @@ -6,6 +6,7 @@ #define BITCOIN_QML_MODELS_WALLETQMLMODEL_H #include +#include #include #include #include @@ -42,6 +43,7 @@ class WalletQmlModel : public QObject Q_PROPERTY(bool customFeeRateValid READ customFeeRateValid NOTIFY customFeeRateValidChanged) Q_PROPERTY(bool feeEstimatePending READ feeEstimatePending NOTIFY feeEstimatePendingChanged) Q_PROPERTY(int feeEstimateRevision READ feeEstimateRevision NOTIFY feeEstimateRevisionChanged) + Q_PROPERTY(BumpTransactionModel* bumpModel READ bumpModel CONSTANT) Q_PROPERTY(bool isWalletLoaded READ isWalletLoaded NOTIFY walletIsLoadedChanged) public: @@ -55,6 +57,7 @@ class WalletQmlModel : public QObject Q_INVOKABLE void commitPaymentRequest(); ActivityListModel* activityListModel() const { return m_activity_list_model; } + BumpTransactionModel* bumpModel() const { return m_bump_transaction_model; } CoinsListModel* coinsListModel() const { return m_coins_list_model; } SendRecipientsListModel* sendRecipientList() const { return m_send_recipients; } PaymentRequest* currentPaymentRequest() const { return m_current_payment_request; } @@ -83,6 +86,8 @@ class WalletQmlModel : public QObject using TransactionChangedFn = std::function; virtual std::unique_ptr handleTransactionChanged(TransactionChangedFn fn); + bool canBumpTransaction(const uint256& txid) const; + interfaces::Wallet::CoinsList listCoins() const; bool lockCoin(const COutPoint& output); bool unlockCoin(const COutPoint& output); @@ -125,6 +130,7 @@ class WalletQmlModel : public QObject std::unique_ptr m_wallet; ActivityListModel* m_activity_list_model{nullptr}; + BumpTransactionModel* m_bump_transaction_model{nullptr}; CoinsListModel* m_coins_list_model{nullptr}; SendRecipientsListModel* m_send_recipients{nullptr}; PaymentRequest* m_current_payment_request{nullptr}; diff --git a/qml/pages/main.qml b/qml/pages/main.qml index 2225048669..cb6d298e13 100644 --- a/qml/pages/main.qml +++ b/qml/pages/main.qml @@ -114,8 +114,7 @@ ApplicationWindow { } onTransactionSent: { walletController.selectedWallet.recipients.clear() - main.pop() - sendResult.open() + main.push(sendResultPage) } } } @@ -128,15 +127,21 @@ ApplicationWindow { } onTransactionSent: { walletController.selectedWallet.recipients.clear() - main.pop() - sendResult.open() + main.push(sendResultPage) } } } - SendResult { - id: sendResult - closePolicy: Popup.CloseOnPressOutside + Component { + id: sendResultPage + SendResult { + onDone: { + main.pop(null) + } + onViewNewTransaction: { + main.pop(null) + } + } } Component { diff --git a/qml/pages/wallet/Activity.qml b/qml/pages/wallet/Activity.qml index a4f835511c..1d582ac1a5 100644 --- a/qml/pages/wallet/Activity.qml +++ b/qml/pages/wallet/Activity.qml @@ -13,6 +13,14 @@ import "../../components" PageStack { id: stackView + function navigateToTransaction(txid) { + if (!walletController.selectedWallet) return + var details = walletController.selectedWallet.activityListModel.transactionDetails(txid) + if (Object.keys(details).length === 0) return + var page = stackView.push("ActivityDetails.qml", details) + page.showTransaction.connect(stackView.navigateToTransaction) + } + Connections { target: walletController function onSelectedWalletChanged() { @@ -99,12 +107,20 @@ PageStack { required property string label; required property int status; required property int type; + required property string txid; + required property bool canBump; + required property string replacedByTxid; HoverHandler { cursorShape: Qt.PointingHandCursor } - onClicked: stackView.push(detailsPage) + opacity: delegate.replacedByTxid !== "" ? 0.4 : 1.0 + + onClicked: { + var page = stackView.push(detailsPage) + page.showTransaction.connect(stackView.navigateToTransaction) + } width: ListView.view.width @@ -190,6 +206,9 @@ PageStack { Component { id: detailsPage ActivityDetails { + txid: delegate.txid + canBump: delegate.canBump + replacedByTxid: delegate.replacedByTxid amount: delegate.amount date: delegate.date depth: delegate.depth diff --git a/qml/pages/wallet/ActivityDetails.qml b/qml/pages/wallet/ActivityDetails.qml index f2ed156604..a0156969e1 100644 --- a/qml/pages/wallet/ActivityDetails.qml +++ b/qml/pages/wallet/ActivityDetails.qml @@ -12,6 +12,11 @@ import "../../components" import "../settings" Page { + signal showTransaction(string txid) + + property string txid: "" + property bool canBump: false + property string replacedByTxid: "" property string message: "" property string amount: "" property string label: "" @@ -23,10 +28,10 @@ Page { property int status: 0 property color iconColor: { - if (delegate.status == Transaction.Confirmed) { - if (delegate.type == Transaction.RecvWithAddress || - delegate.type == Transaction.RecvFromOther || - delegate.type == Transaction.Generated) { + if (root.status == Transaction.Confirmed) { + if (root.type == Transaction.RecvWithAddress || + root.type == Transaction.RecvFromOther || + root.type == Transaction.Generated) { Theme.color.green } else { Theme.color.orange @@ -36,9 +41,9 @@ Page { } } property color amountColor: { - if (delegate.type == Transaction.RecvWithAddress - || delegate.type == Transaction.RecvFromOther - || delegate.type == Transaction.Generated) { + if (root.type == Transaction.RecvWithAddress + || root.type == Transaction.RecvFromOther + || root.type == Transaction.Generated) { Theme.color.green } else { Theme.color.neutral9 @@ -80,6 +85,7 @@ Page { id: columnLayout anchors.horizontalCenter: parent.horizontalCenter width: Math.min(parent.width, 450) + opacity: root.replacedByTxid !== "" ? 0.4 : 1.0 spacing: 0 Rectangle { @@ -192,6 +198,56 @@ Page { } } } + + InfoBanner { + objectName: "speedUpBanner" + visible: root.canBump + Layout.fillWidth: true + Layout.topMargin: 20 + bannerLayout: InfoBanner.Layout.Vertical + title: qsTr("Speed up") + message: qsTr("This transaction is still unconfirmed. You can speed it up by increasing the fee.") + primaryButtonText: qsTr("Speed up") + onPrimaryClicked: speedUpOverlay.open() + } + + } + } + + InfoBanner { + visible: root.replacedByTxid !== "" + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 20 + anchors.bottomMargin: 30 + title: qsTr("This transaction was updated with a faster one") + message: qsTr("You increased the fee while this transaction was still unconfirmed. Only the new one will confirm on-chain.") + primaryButtonText: qsTr("View updated transaction") + onPrimaryClicked: root.showTransaction(root.replacedByTxid) + } + + SpeedUpOverlay { + id: speedUpOverlay + txid: root.txid + anchors.centerIn: parent + onBumpSucceeded: { + var page = root.StackView.view.push("SendResult.qml", { + resultType: SendResult.ResultType.SpeedUp + }) + page.done.connect(function() { + if (walletController.selectedWallet) { + walletController.selectedWallet.activityListModel.reload() + } + root.StackView.view.pop(null) + }) + page.viewNewTransaction.connect(function() { + if (walletController.selectedWallet) { + walletController.selectedWallet.activityListModel.reload() + } + root.StackView.view.pop(null) + root.showTransaction(speedUpOverlay.newTxid) + }) } } } diff --git a/qml/pages/wallet/SendResult.qml b/qml/pages/wallet/SendResult.qml index 3b119aa7c4..d6be1cae67 100644 --- a/qml/pages/wallet/SendResult.qml +++ b/qml/pages/wallet/SendResult.qml @@ -5,30 +5,34 @@ import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 -import QtQuick.Dialogs import org.bitcoincore.qt 1.0 import "../../controls" import "../../components" -Popup { +Page { id: root - modal: true - anchors.centerIn: parent - background: Rectangle { - anchors.centerIn: parent - width: columnLayout.width + 40 - height: columnLayout.height + 40 - color: Theme.color.neutral0 - border.color: Theme.color.neutral4 - border.width: 1 - radius: 5 + color: Theme.color.background + } + + enum ResultType { + Regular, + SpeedUp + /*, Cancel */ } + property int resultType: SendResult.ResultType.Regular + + signal done() + signal viewNewTransaction() + ColumnLayout { id: columnLayout - anchors.centerIn: parent + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: -60 + width: Math.min(parent.width - 80, 450) spacing: 20 Item { @@ -37,7 +41,6 @@ Popup { Layout.alignment: Qt.AlignHCenter Rectangle { anchors.fill: parent - Layout.alignment: Qt.AlignHCenter radius: 30 color: Theme.color.green opacity: 0.2 @@ -47,13 +50,14 @@ Popup { source: "qrc:/icons/check" color: Theme.color.green size: 30 - opacity: 1.0 } } CoreText { Layout.alignment: Qt.AlignHCenter - text: qsTr("Transaction sent") + text: root.resultType === SendResult.ResultType.SpeedUp + ? qsTr("Transaction updated") + : qsTr("Transaction sent") font.pixelSize: 28 bold: true } @@ -61,26 +65,33 @@ Popup { CoreText { Layout.alignment: Qt.AlignHCenter Layout.maximumWidth: 350 + Layout.topMargin: 10 + Layout.bottomMargin: 20 color: Theme.color.neutral7 text: qsTr("Based on your selected fee, it should be confirmed within the next 10 minutes.") font.pixelSize: 18 } - ContinueButton { - Layout.preferredWidth: Math.min(200, parent.width - 2 * Layout.leftMargin) - Layout.leftMargin: 20 - Layout.rightMargin: Layout.leftMargin + RowLayout { Layout.alignment: Qt.AlignCenter - text: qsTr("Close window") - borderColor: Theme.color.neutral6 - borderHoverColor: Theme.color.neutral9 - borderPressedColor: Theme.color.neutral9 - textColor: Theme.color.neutral9 - backgroundColor: "transparent" - backgroundHoverColor: "transparent" - backgroundPressedColor: "transparent" - onClicked: { - root.close() + spacing: 15 + + OutlineButton { + text: root.resultType === SendResult.ResultType.SpeedUp + ? qsTr("View new transaction") + : qsTr("View transaction") + Layout.fillWidth: true + Layout.preferredWidth: 1 + Layout.minimumWidth: 150 + onClicked: root.viewNewTransaction() + } + + ContinueButton { + Layout.fillWidth: true + Layout.preferredWidth: 1 + Layout.minimumWidth: 150 + text: qsTr("Done") + onClicked: root.done() } } } diff --git a/qml/pages/wallet/SpeedUpOverlay.qml b/qml/pages/wallet/SpeedUpOverlay.qml new file mode 100644 index 0000000000..4de868e04e --- /dev/null +++ b/qml/pages/wallet/SpeedUpOverlay.qml @@ -0,0 +1,181 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import org.bitcoincore.qt 1.0 + +import "../../controls" +import "../../components" + +Popup { + id: root + objectName: "speedUpOverlay" + + property string txid: "" + property var bumpModel: walletController.selectedWallet + ? walletController.selectedWallet.bumpModel : null + + signal done() + signal viewNewTransaction(string newTxid) + + modal: true + leftPadding: 40 + rightPadding: 40 + topPadding: 30 + bottomPadding: 30 + width: 500 + anchors.centerIn: parent + + onOpened: { + if (root.bumpModel) { + root.bumpModel.prepareFeeBump(root.txid, 1) + } + } + + onClosed: { + if (root.bumpModel) { + root.bumpModel.reset() + } + } + + background: Rectangle { + color: Theme.color.neutral0 + radius: 10 + border.color: Theme.color.neutral4 + border.width: 1 + } + + property string newTxid: "" + + signal bumpSucceeded() + + Connections { + target: root.bumpModel + function onStateChanged() { + if (root.bumpModel && root.bumpModel.state === BumpTransactionModel.Succeeded) { + root.newTxid = root.bumpModel.newTxid + root.close() + root.bumpSucceeded() + } + } + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + RowLayout { + Layout.fillWidth: true + CoreText { + text: qsTr("Speed up transaction") + font.pixelSize: 21 + bold: true + horizontalAlignment: Text.AlignLeft + Layout.fillWidth: true + } + Icon { + source: "image://images/cross" + color: Theme.color.neutral8 + size: 10 + enabled: true + padding: 0 + onClicked: root.close() + HoverHandler { + cursorShape: Qt.PointingHandCursor + } + } + } + + CoreText { + Layout.fillWidth: true + Layout.topMargin: 15 + Layout.bottomMargin: 25 + horizontalAlignment: Text.AlignLeft + text: qsTr("Set a higher transaction fee if you want your transaction to be confirmed faster.") + font.pixelSize: 15 + color: Theme.color.neutral7 + wrap: true + } + + RowLayout { + Layout.fillWidth: true + CoreText { + text: qsTr("Original fee") + font.pixelSize: 15 + color: Theme.color.neutral7 + } + CoreText { + text: root.bumpModel ? root.bumpModel.oldFee : "" + font.pixelSize: 15 + color: Theme.color.neutral9 + horizontalAlignment: Text.AlignRight + Layout.fillWidth: true + } + //FIXME: Add label to show estimated confirmation duration (~20 min) + } + + Separator { + Layout.fillWidth: true + Layout.topMargin: 10 + Layout.bottomMargin: 10 + } + + RowLayout { + Layout.fillWidth: true + CoreText { + text: qsTr("New fee") + font.pixelSize: 15 + color: Theme.color.neutral7 + } + CoreText { + text: root.bumpModel ? root.bumpModel.newFee : "" + font.pixelSize: 15 + color: Theme.color.neutral9 + horizontalAlignment: Text.AlignRight + Layout.fillWidth: true + } + } + + CoreText { + visible: root.bumpModel && root.bumpModel.state === BumpTransactionModel.Failed + text: root.bumpModel ? root.bumpModel.errorText : "" + font.pixelSize: 15 + color: Theme.color.red + Layout.fillWidth: true + Layout.topMargin: 10 + wrap: true + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 25 + spacing: 15 + + OutlineButton { + text: qsTr("Cancel") + Layout.fillWidth: true + Layout.preferredWidth: 1 + onClicked: root.close() + } + + ContinueButton { + objectName: "updateTransactionButton" + text: qsTr("Update transaction") + Layout.fillWidth: true + Layout.preferredWidth: 1 + enabled: root.bumpModel + && root.bumpModel.state === BumpTransactionModel.NeedsConfirmation + //FIXME: Unlock wallet before confirming (PR #548) + onClicked: { + if (root.bumpModel) { + root.bumpModel.confirmFeeBump() + } + } + } + } + } + +} From 5999e805e765e31368849ea42af732d2a5509409 Mon Sep 17 00:00:00 2001 From: pseudoramdom Date: Fri, 24 Apr 2026 14:55:42 -0700 Subject: [PATCH 2/4] test: Add bump transaction model unit tests & qml tests --- qml/pages/wallet/Activity.qml | 1 + qml/pages/wallet/ActivityDetails.qml | 2 +- test/CMakeLists.txt | 1 + test/mocks/mockwallet.h | 4 + test/qml/qml_tests_main.cpp | 103 ++++++++++++- test/qml/tst_activitydetails.qml | 132 ++++++++++++++++ test/test_bumptransactionmodel.cpp | 223 +++++++++++++++++++++++++++ test/test_unit_tests_main.cpp | 2 + 8 files changed, 464 insertions(+), 4 deletions(-) create mode 100644 test/qml/tst_activitydetails.qml create mode 100644 test/test_bumptransactionmodel.cpp diff --git a/qml/pages/wallet/Activity.qml b/qml/pages/wallet/Activity.qml index 1d582ac1a5..f5001581d3 100644 --- a/qml/pages/wallet/Activity.qml +++ b/qml/pages/wallet/Activity.qml @@ -100,6 +100,7 @@ PageStack { model: walletController.selectedWallet.activityListModel delegate: ItemDelegate { id: delegate + objectName: "activityItem_" + delegate.txid required property string address; required property string amount; required property string date; diff --git a/qml/pages/wallet/ActivityDetails.qml b/qml/pages/wallet/ActivityDetails.qml index a0156969e1..f61802e066 100644 --- a/qml/pages/wallet/ActivityDetails.qml +++ b/qml/pages/wallet/ActivityDetails.qml @@ -205,7 +205,6 @@ Page { Layout.fillWidth: true Layout.topMargin: 20 bannerLayout: InfoBanner.Layout.Vertical - title: qsTr("Speed up") message: qsTr("This transaction is still unconfirmed. You can speed it up by increasing the fee.") primaryButtonText: qsTr("Speed up") onPrimaryClicked: speedUpOverlay.open() @@ -215,6 +214,7 @@ Page { } InfoBanner { + objectName: "replacedBanner" visible: root.replacedByTxid !== "" anchors.left: parent.left anchors.right: parent.right diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 1e640c3f39..4a2bc23bcb 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -22,6 +22,7 @@ add_executable(bitcoinqml_unit_tests test_options_model.cpp test_banlistmodel.cpp test_walletqmlmodel.cpp + test_bumptransactionmodel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../bitcoin/src/util/chaintype.cpp ) diff --git a/test/mocks/mockwallet.h b/test/mocks/mockwallet.h index caa423381f..55644d528c 100644 --- a/test/mocks/mockwallet.h +++ b/test/mocks/mockwallet.h @@ -125,6 +125,10 @@ class MockWallet : public StubWallet MOCK_METHOD(CoinsList, listCoins, (), (override)); MOCK_METHOD(OutputType, getDefaultAddressType, (), (override)); MOCK_METHOD((std::unique_ptr), handleTransactionChanged, (TransactionChangedFn), (override)); + MOCK_METHOD(bool, transactionCanBeBumped, (const Txid&), (override)); + MOCK_METHOD(bool, createBumpTransaction, (const Txid&, const wallet::CCoinControl&, std::vector&, CAmount&, CAmount&, CMutableTransaction&), (override)); + MOCK_METHOD(bool, signBumpTransaction, (CMutableTransaction&), (override)); + MOCK_METHOD(bool, commitBumpTransaction, (const Txid&, CMutableTransaction&&, std::vector&, Txid&), (override)); }; #endif // BITCOIN_QML_TEST_MOCKS_MOCKWALLET_H diff --git a/test/qml/qml_tests_main.cpp b/test/qml/qml_tests_main.cpp index 416d48c0f7..b675780758 100644 --- a/test/qml/qml_tests_main.cpp +++ b/test/qml/qml_tests_main.cpp @@ -515,6 +515,7 @@ class MockWalletQmlModel : public QObject Q_PROPERTY(QString name MEMBER m_name NOTIFY nameChanged) Q_PROPERTY(QString balance MEMBER m_balance NOTIFY balanceChanged) Q_PROPERTY(QObject* activityListModel READ activityListModel CONSTANT) + Q_PROPERTY(QObject* bumpModel READ bumpModel CONSTANT) Q_PROPERTY(QObject* recipients READ recipients CONSTANT) Q_PROPERTY(QObject* coinsListModel READ coinsListModel CONSTANT) Q_PROPERTY(QObject* currentTransaction READ currentTransaction CONSTANT) @@ -535,6 +536,7 @@ class MockWalletQmlModel : public QObject QString m_name{QStringLiteral("testwallet")}; QString m_balance{QStringLiteral("1.00000000 BTC")}; QObject* m_activity_list_model{nullptr}; + QObject* m_bump_model{nullptr}; QObject* m_recipients{nullptr}; QObject* m_coins_list_model{nullptr}; QObject* m_current_transaction{nullptr}; @@ -543,6 +545,7 @@ class MockWalletQmlModel : public QObject bool m_prepare_transaction_result{true}; QObject* activityListModel() const { return m_activity_list_model; } + QObject* bumpModel() const { return m_bump_model; } QObject* recipients() const { return m_recipients; } QObject* coinsListModel() const { return m_coins_list_model; } QObject* currentTransaction() const { return m_current_transaction; } @@ -584,6 +587,7 @@ class MockWalletQmlModel : public QObject } } void setActivityListModel(QObject* model) { m_activity_list_model = model; } + void setBumpModel(QObject* model) { m_bump_model = model; } void setRecipients(QObject* model) { m_recipients = model; } void setCoinsListModel(QObject* model) { m_coins_list_model = model; } void setCurrentTransaction(QObject* transaction) { m_current_transaction = transaction; } @@ -949,6 +953,78 @@ class MockWalletListModel : public QAbstractListModel QStringList m_wallet_names{QStringLiteral("testwallet"), QStringLiteral("secondarywallet")}; }; +class MockBumpTransactionModel : public QObject +{ + Q_OBJECT + Q_PROPERTY(int state READ state WRITE setState NOTIFY stateChanged) + Q_PROPERTY(QString oldFee MEMBER m_old_fee NOTIFY resultChanged) + Q_PROPERTY(QString newFee MEMBER m_new_fee NOTIFY resultChanged) + Q_PROPERTY(QString feeIncrease MEMBER m_fee_increase NOTIFY resultChanged) + Q_PROPERTY(QString oldTxid MEMBER m_old_txid NOTIFY resultChanged) + Q_PROPERTY(QString newTxid MEMBER m_new_txid NOTIFY resultChanged) + Q_PROPERTY(QString errorText MEMBER m_error_text NOTIFY resultChanged) + +public: + enum State { Idle, Preparing, NeedsConfirmation, Committing, Succeeded, Failed }; + Q_ENUM(State) + + enum ActionType { SpeedUp }; + Q_ENUM(ActionType) + + int state() const { return m_state; } + void setState(int state) + { + if (m_state == state) return; + m_state = state; + Q_EMIT stateChanged(); + } + + Q_INVOKABLE void prepareFeeBump(const QString& txid, unsigned int targetBlocks) + { + Q_UNUSED(targetBlocks); + m_old_txid = txid; + m_old_fee = QStringLiteral("0.00000500 ₿"); + m_new_fee = QStringLiteral("0.00001000 ₿"); + m_fee_increase = QStringLiteral("0.00000500 ₿"); + setState(NeedsConfirmation); + Q_EMIT resultChanged(); + } + + Q_INVOKABLE void confirmFeeBump() + { + m_new_txid = QStringLiteral("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + setState(Succeeded); + Q_EMIT resultChanged(); + } + + Q_INVOKABLE void reset() + { + m_state = Idle; + m_old_fee.clear(); + m_new_fee.clear(); + m_fee_increase.clear(); + m_old_txid.clear(); + m_new_txid.clear(); + m_error_text.clear(); + Q_EMIT stateChanged(); + Q_EMIT resultChanged(); + } + +Q_SIGNALS: + void stateChanged(); + void actionTypeChanged(); + void resultChanged(); + +private: + int m_state{Idle}; + QString m_old_fee; + QString m_new_fee; + QString m_fee_increase; + QString m_old_txid; + QString m_new_txid; + QString m_error_text; +}; + class MockActivityListModel : public QAbstractListModel { Q_OBJECT @@ -961,7 +1037,11 @@ class MockActivityListModel : public QAbstractListModel DepthRole, LabelRole, StatusRole, - TypeRole + TypeRole, + TxidRole, + CanBumpRole, + ReplacesTxidRole, + ReplacedByTxidRole }; int rowCount(const QModelIndex& parent = QModelIndex{}) const override @@ -982,6 +1062,10 @@ class MockActivityListModel : public QAbstractListModel case LabelRole: return QStringLiteral("salary"); case StatusRole: return MockTransaction::Confirmed; case TypeRole: return MockTransaction::RecvWithAddress; + case TxidRole: return QStringLiteral("aaaa"); + case CanBumpRole: return false; + case ReplacesTxidRole: return QString{}; + case ReplacedByTxidRole: return QString{}; default: return {}; } } @@ -989,10 +1073,14 @@ class MockActivityListModel : public QAbstractListModel case AddressRole: return QStringLiteral("bcrt1qsendaddress"); case AmountRole: return QStringLiteral("-0.00100000 BTC"); case DateRole: return QStringLiteral("2026-01-02 00:00"); - case DepthRole: return 1; + case DepthRole: return 0; case LabelRole: return QStringLiteral("coffee"); - case StatusRole: return MockTransaction::Confirming; + case StatusRole: return MockTransaction::Unconfirmed; case TypeRole: return MockTransaction::SendToAddress; + case TxidRole: return QStringLiteral("bbbb"); + case CanBumpRole: return true; + case ReplacesTxidRole: return QString{}; + case ReplacedByTxidRole: return QString{}; default: return {}; } } @@ -1007,8 +1095,14 @@ class MockActivityListModel : public QAbstractListModel {LabelRole, "label"}, {StatusRole, "status"}, {TypeRole, "type"}, + {TxidRole, "txid"}, + {CanBumpRole, "canBump"}, + {ReplacesTxidRole, "replacesTxid"}, + {ReplacedByTxidRole, "replacedByTxid"}, }; } + + Q_INVOKABLE void reload() {} }; class QmlTestsSetup : public QObject @@ -1036,8 +1130,10 @@ public Q_SLOTS: static MockWalletController wallet_controller; static MockWalletListModel wallet_list_model; static MockActivityListModel activity_list_model; + static MockBumpTransactionModel bump_model; recipients_model.setCurrent(&send_recipient); wallet_model.setActivityListModel(&activity_list_model); + wallet_model.setBumpModel(&bump_model); wallet_model.setRecipients(&recipients_model); wallet_model.setCoinsListModel(&coins_list_model); wallet_model.setCurrentTransaction(&wallet_transaction); @@ -1056,6 +1152,7 @@ public Q_SLOTS: qmlRegisterType("org.bitcoincore.qt", 1, 0, "PaymentRequest"); qmlRegisterUncreatableType("org.bitcoincore.qt", 1, 0, "Transaction", "Test stub type"); qmlRegisterUncreatableType("org.bitcoincore.qt", 1, 0, "SendRecipient", "Test stub type"); + qmlRegisterUncreatableType("org.bitcoincore.qt", 1, 0, "BumpTransactionModel", "Test stub type"); qmlRegisterUncreatableType("org.bitcoincore.qt", 1, 0, "WalletQmlModel", "Test stub type"); qmlRegisterUncreatableType( "org.bitcoincore.qt", diff --git a/test/qml/tst_activitydetails.qml b/test/qml/tst_activitydetails.qml new file mode 100644 index 0000000000..19184daa84 --- /dev/null +++ b/test/qml/tst_activitydetails.qml @@ -0,0 +1,132 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 +import QtTest 1.2 +import "../../qml/pages/wallet" + +TestCase { + name: "ActivityDetails" + when: windowShown + width: 900 + height: 700 + + Component { + id: detailsComponent + ActivityDetails { + width: 600 + height: 700 + } + } + + function test_speedUpBanner_exists_when_bumpable() { + const page = createTemporaryObject(detailsComponent, this, { + txid: "bbbb", + canBump: true, + amount: "-0.00100000 BTC", + date: "2026-01-02", + depth: 0, + status: 0, + type: 3, + address: "bcrt1qsendaddress" + }) + verify(page !== null) + compare(page.canBump, true) + + const banner = findChild(page, "speedUpBanner") + verify(banner !== null) + } + + function test_canBump_false_hides_speedUpBanner() { + const page = createTemporaryObject(detailsComponent, this, { + txid: "aaaa", + canBump: false, + amount: "+0.01000000 BTC", + date: "2026-01-01", + depth: 3, + status: 2, + type: 1, + address: "bcrt1qreceiveaddress" + }) + verify(page !== null) + compare(page.canBump, false) + + const banner = findChild(page, "speedUpBanner") + verify(banner !== null) + compare(banner.visible, false) + } + + function test_confirmed_tx_hides_speedUpBanner() { + const page = createTemporaryObject(detailsComponent, this, { + txid: "cccc", + canBump: false, + amount: "-0.00500000 BTC", + date: "2026-01-03", + depth: 6, + status: 2, + type: 3, + address: "bcrt1qconfirmedaddress" + }) + verify(page !== null) + + const banner = findChild(page, "speedUpBanner") + verify(banner !== null) + compare(banner.visible, false) + } + + function test_replacedByTxid_shows_replacedBanner() { + const page = createTemporaryObject(detailsComponent, this, { + txid: "aaaa", + canBump: false, + replacedByTxid: "bbbb", + amount: "-0.00100000 BTC", + date: "2026-01-02", + depth: -1, + status: 0, + type: 3, + address: "bcrt1qsendaddress" + }) + verify(page !== null) + compare(page.replacedByTxid, "bbbb") + + const banner = findChild(page, "replacedBanner") + verify(banner !== null) + } + + function test_no_replacedByTxid_hides_replacedBanner() { + const page = createTemporaryObject(detailsComponent, this, { + txid: "bbbb", + canBump: true, + replacedByTxid: "", + amount: "-0.00100000 BTC", + date: "2026-01-02", + depth: 0, + status: 0, + type: 3, + address: "bcrt1qsendaddress" + }) + verify(page !== null) + + const banner = findChild(page, "replacedBanner") + verify(banner !== null) + compare(banner.visible, false) + } + + function test_dimmed_when_replaced() { + const page = createTemporaryObject(detailsComponent, this, { + txid: "aaaa", + canBump: false, + replacedByTxid: "bbbb", + amount: "-0.00100000 BTC", + date: "2026-01-02", + depth: -1, + status: 0, + type: 3, + address: "bcrt1qsendaddress" + }) + verify(page !== null) + compare(page.replacedByTxid, "bbbb") + compare(page.opacity !== undefined, true) + } +} diff --git a/test/test_bumptransactionmodel.cpp b/test/test_bumptransactionmodel.cpp new file mode 100644 index 0000000000..3ba913bb89 --- /dev/null +++ b/test/test_bumptransactionmodel.cpp @@ -0,0 +1,223 @@ +// Copyright (c) 2026 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 + +namespace { +using ::testing::Invoke; +using ::testing::NiceMock; +using ::testing::Return; + +const auto TEST_TXID = QStringLiteral("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + +std::unique_ptr> MakeMockWallet() +{ + auto wallet = std::make_unique>(); + ON_CALL(*wallet, transactionCanBeBumped(testing::_)).WillByDefault(Return(true)); + return wallet; +} +} // namespace + +class BumpTransactionModelTests : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void stateIsIdleByDefault() + { + auto wallet = MakeMockWallet(); + BumpTransactionModel model(wallet.get()); + QCOMPARE(model.state(), BumpTransactionModel::Idle); + } + + void prepareFeeBump_transitionsToNeedsConfirmation() + { + auto wallet = MakeMockWallet(); + ON_CALL(*wallet, createBumpTransaction(testing::_, testing::_, testing::_, testing::_, testing::_, testing::_)) + .WillByDefault(Invoke([](const Txid&, const wallet::CCoinControl&, std::vector&, + CAmount& old_fee, CAmount& new_fee, CMutableTransaction&) { + old_fee = 500; + new_fee = 1000; + return true; + })); + + BumpTransactionModel model(wallet.get()); + QSignalSpy stateSpy(&model, &BumpTransactionModel::stateChanged); + + model.prepareFeeBump(TEST_TXID, 1); + + QCOMPARE(model.state(), BumpTransactionModel::NeedsConfirmation); + QVERIFY(stateSpy.count() >= 2); // Idle -> Preparing -> NeedsConfirmation + } + + void prepareFeeBump_populatesFees() + { + auto wallet = MakeMockWallet(); + ON_CALL(*wallet, createBumpTransaction(testing::_, testing::_, testing::_, testing::_, testing::_, testing::_)) + .WillByDefault(Invoke([](const Txid&, const wallet::CCoinControl&, std::vector&, + CAmount& old_fee, CAmount& new_fee, CMutableTransaction&) { + old_fee = 500; + new_fee = 1000; + return true; + })); + + BumpTransactionModel model(wallet.get()); + model.prepareFeeBump(TEST_TXID, 1); + + QVERIFY(!model.oldFee().isEmpty()); + QVERIFY(!model.newFee().isEmpty()); + QVERIFY(!model.feeIncrease().isEmpty()); + } + + void prepareFeeBump_failureSetsErrorState() + { + auto wallet = MakeMockWallet(); + ON_CALL(*wallet, createBumpTransaction(testing::_, testing::_, testing::_, testing::_, testing::_, testing::_)) + .WillByDefault(Invoke([](const Txid&, const wallet::CCoinControl&, std::vector& errors, + CAmount&, CAmount&, CMutableTransaction&) { + errors.emplace_back(Untranslated("insufficient fee")); + return false; + })); + + BumpTransactionModel model(wallet.get()); + model.prepareFeeBump(TEST_TXID, 1); + + QCOMPARE(model.state(), BumpTransactionModel::Failed); + QVERIFY(!model.errorText().isEmpty()); + } + + void prepareFeeBump_invalidTxidFails() + { + auto wallet = MakeMockWallet(); + BumpTransactionModel model(wallet.get()); + + model.prepareFeeBump(QStringLiteral("not-a-txid"), 1); + + QCOMPARE(model.state(), BumpTransactionModel::Failed); + } + + void prepareFeeBump_nullWalletFails() + { + BumpTransactionModel model(nullptr); + + model.prepareFeeBump(TEST_TXID, 1); + + QCOMPARE(model.state(), BumpTransactionModel::Failed); + } + + void confirmFeeBump_signsAndCommits() + { + auto wallet = MakeMockWallet(); + ON_CALL(*wallet, createBumpTransaction(testing::_, testing::_, testing::_, testing::_, testing::_, testing::_)) + .WillByDefault(Invoke([](const Txid&, const wallet::CCoinControl&, std::vector&, + CAmount& old_fee, CAmount& new_fee, CMutableTransaction&) { + old_fee = 500; + new_fee = 1000; + return true; + })); + ON_CALL(*wallet, signBumpTransaction(testing::_)).WillByDefault(Return(true)); + ON_CALL(*wallet, commitBumpTransaction(testing::_, testing::_, testing::_, testing::_)) + .WillByDefault(Invoke([](const Txid&, CMutableTransaction&&, std::vector&, Txid& bumped_txid) { + bumped_txid = Txid::FromUint256(*uint256::FromHex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")); + return true; + })); + + BumpTransactionModel model(wallet.get()); + model.prepareFeeBump(TEST_TXID, 1); + QCOMPARE(model.state(), BumpTransactionModel::NeedsConfirmation); + + model.confirmFeeBump(); + + QCOMPARE(model.state(), BumpTransactionModel::Succeeded); + QVERIFY(!model.newTxid().isEmpty()); + } + + void confirmFeeBump_signFailureDoesNotCommit() + { + auto wallet = MakeMockWallet(); + ON_CALL(*wallet, createBumpTransaction(testing::_, testing::_, testing::_, testing::_, testing::_, testing::_)) + .WillByDefault(Invoke([](const Txid&, const wallet::CCoinControl&, std::vector&, + CAmount& old_fee, CAmount& new_fee, CMutableTransaction&) { + old_fee = 500; + new_fee = 1000; + return true; + })); + ON_CALL(*wallet, signBumpTransaction(testing::_)).WillByDefault(Return(false)); + EXPECT_CALL(*wallet, commitBumpTransaction(testing::_, testing::_, testing::_, testing::_)).Times(0); + + BumpTransactionModel model(wallet.get()); + model.prepareFeeBump(TEST_TXID, 1); + + model.confirmFeeBump(); + + QCOMPARE(model.state(), BumpTransactionModel::Failed); + } + + void confirmFeeBump_rejectsWhenNotReady() + { + auto wallet = MakeMockWallet(); + BumpTransactionModel model(wallet.get()); + + model.confirmFeeBump(); + + QCOMPARE(model.state(), BumpTransactionModel::Idle); + } + + void confirmFeeBump_rechecksEligibility() + { + auto wallet = MakeMockWallet(); + ON_CALL(*wallet, createBumpTransaction(testing::_, testing::_, testing::_, testing::_, testing::_, testing::_)) + .WillByDefault(Invoke([](const Txid&, const wallet::CCoinControl&, std::vector&, + CAmount& old_fee, CAmount& new_fee, CMutableTransaction&) { + old_fee = 500; + new_fee = 1000; + return true; + })); + + BumpTransactionModel model(wallet.get()); + model.prepareFeeBump(TEST_TXID, 1); + + ON_CALL(*wallet, transactionCanBeBumped(testing::_)).WillByDefault(Return(false)); + model.confirmFeeBump(); + + QCOMPARE(model.state(), BumpTransactionModel::Failed); + } + + void reset_clearsState() + { + auto wallet = MakeMockWallet(); + ON_CALL(*wallet, createBumpTransaction(testing::_, testing::_, testing::_, testing::_, testing::_, testing::_)) + .WillByDefault(Invoke([](const Txid&, const wallet::CCoinControl&, std::vector&, + CAmount& old_fee, CAmount& new_fee, CMutableTransaction&) { + old_fee = 500; + new_fee = 1000; + return true; + })); + + BumpTransactionModel model(wallet.get()); + model.prepareFeeBump(TEST_TXID, 1); + QCOMPARE(model.state(), BumpTransactionModel::NeedsConfirmation); + + model.reset(); + + QCOMPARE(model.state(), BumpTransactionModel::Idle); + QVERIFY(model.newTxid().isEmpty()); + QVERIFY(model.errorText().isEmpty()); + } +}; + +int RunBumpTransactionModelTests(int argc, char* argv[]) +{ + BumpTransactionModelTests test; + return QTest::qExec(&test, argc, argv); +} + +#include "test_bumptransactionmodel.moc" diff --git a/test/test_unit_tests_main.cpp b/test/test_unit_tests_main.cpp index ab1c4e47d5..ac15432026 100644 --- a/test/test_unit_tests_main.cpp +++ b/test/test_unit_tests_main.cpp @@ -19,6 +19,7 @@ int RunQmlInitExecutorApiTests(int argc, char* argv[]); int RunOptionsModelTests(int argc, char* argv[]); int RunBanListModelTests(int argc, char* argv[]); int RunWalletQmlModelTests(int argc, char* argv[]); +int RunBumpTransactionModelTests(int argc, char* argv[]); int main(int argc, char* argv[]) { @@ -37,6 +38,7 @@ int main(int argc, char* argv[]) status |= RunOptionsModelTests(argc, argv); status |= RunBanListModelTests(argc, argv); status |= RunWalletQmlModelTests(argc, argv); + status |= RunBumpTransactionModelTests(argc, argv); return status; } From 2cf482baa0e920e56108ef23794703a87907f5be Mon Sep 17 00:00:00 2001 From: pseudoramdom Date: Sat, 25 Apr 2026 14:48:56 -0700 Subject: [PATCH 3/4] test: Add functional tests for RBF flow --- qml/components/InfoBanner.qml | 4 + qml/models/activitylistmodel.cpp | 10 +- qml/pages/wallet/SpeedUpOverlay.qml | 8 +- test/functional/qml_test_harness.py | 1 + test/functional/qml_test_wallet_rbf.py | 257 +++++++++++++++++++++++++ test/functional/qml_wallet_test_lib.py | 1 + 6 files changed, 277 insertions(+), 4 deletions(-) create mode 100644 test/functional/qml_test_wallet_rbf.py diff --git a/qml/components/InfoBanner.qml b/qml/components/InfoBanner.qml index 1feb4c1154..0442bac8f3 100644 --- a/qml/components/InfoBanner.qml +++ b/qml/components/InfoBanner.qml @@ -79,12 +79,14 @@ Rectangle { } OutlineButton { + objectName: root.objectName !== "" ? root.objectName + "DismissButton" : "" visible: root.dismissButtonText !== "" text: root.dismissButtonText onClicked: root.dismissClicked() } ContinueButton { + objectName: root.objectName !== "" ? root.objectName + "PrimaryButton" : "" visible: root.primaryButtonText !== "" text: root.primaryButtonText onClicked: root.primaryClicked() @@ -131,6 +133,7 @@ Rectangle { spacing: 15 OutlineButton { + objectName: root.objectName !== "" ? root.objectName + "DismissButton" : "" visible: root.dismissButtonText !== "" text: root.dismissButtonText Layout.maximumWidth: 200 @@ -138,6 +141,7 @@ Rectangle { } ContinueButton { + objectName: root.objectName !== "" ? root.objectName + "PrimaryButton" : "" visible: root.primaryButtonText !== "" text: root.primaryButtonText Layout.maximumWidth: 200 diff --git a/qml/models/activitylistmodel.cpp b/qml/models/activitylistmodel.cpp index fd91c05ef7..55387813c0 100644 --- a/qml/models/activitylistmodel.cpp +++ b/qml/models/activitylistmodel.cpp @@ -166,11 +166,17 @@ void ActivityListModel::updateTransaction(const uint256& hash, const interfaces: // new transaction interfaces::WalletTx wtx = m_wallet_model->getWalletTx(hash); auto transactions = Transaction::fromWalletTx(wtx); + if (transactions.isEmpty()) { + return; + } for (const auto& tx : transactions) { tx->updateStatus(tx_status, num_blocks, block_time); - m_transactions.push_front(tx); } - Q_EMIT dataChanged(this->index(0), this->index(m_transactions.size() - 1)); + beginInsertRows(QModelIndex(), 0, transactions.size() - 1); + for (auto it = transactions.crbegin(); it != transactions.crend(); ++it) { + m_transactions.push_front(*it); + } + endInsertRows(); } } diff --git a/qml/pages/wallet/SpeedUpOverlay.qml b/qml/pages/wallet/SpeedUpOverlay.qml index 4de868e04e..02c1747527 100644 --- a/qml/pages/wallet/SpeedUpOverlay.qml +++ b/qml/pages/wallet/SpeedUpOverlay.qml @@ -17,6 +17,10 @@ Popup { property string txid: "" property var bumpModel: walletController.selectedWallet ? walletController.selectedWallet.bumpModel : null + property int bumpState: root.bumpModel ? root.bumpModel.state : BumpTransactionModel.Idle + property string bumpErrorText: root.bumpModel ? root.bumpModel.errorText : "" + property bool readyToConfirm: root.bumpModel + && root.bumpModel.state === BumpTransactionModel.NeedsConfirmation signal done() signal viewNewTransaction(string newTxid) @@ -140,6 +144,7 @@ Popup { } CoreText { + objectName: "speedUpErrorText" visible: root.bumpModel && root.bumpModel.state === BumpTransactionModel.Failed text: root.bumpModel ? root.bumpModel.errorText : "" font.pixelSize: 15 @@ -166,8 +171,7 @@ Popup { text: qsTr("Update transaction") Layout.fillWidth: true Layout.preferredWidth: 1 - enabled: root.bumpModel - && root.bumpModel.state === BumpTransactionModel.NeedsConfirmation + enabled: root.readyToConfirm //FIXME: Unlock wallet before confirming (PR #548) onClicked: { if (root.bumpModel) { diff --git a/test/functional/qml_test_harness.py b/test/functional/qml_test_harness.py index d7827a4f9a..1dbd815df4 100755 --- a/test/functional/qml_test_harness.py +++ b/test/functional/qml_test_harness.py @@ -61,6 +61,7 @@ def setup_datadir(tmpdir): f.write("printtoconsole=0\n") f.write("connect=0\n") f.write("shrinkdebugfile=0\n") + f.write("fallbackfee=0.0001\n") return datadir diff --git a/test/functional/qml_test_wallet_rbf.py b/test/functional/qml_test_wallet_rbf.py new file mode 100644 index 0000000000..8dd983f6f4 --- /dev/null +++ b/test/functional/qml_test_wallet_rbf.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""End-to-end GUI test for RBF (Replace-By-Fee) speed up flow.""" + +import sys +import time +from decimal import Decimal + +from qml_test_harness import dump_qml_tree +from qml_wallet_test_lib import WalletFlowHarness, rpc_call + + +GUI_WALLET_NAME = "rbf_wallet" +RECEIVER_WALLET_NAME = "rbf_receiver" +SEND_AMOUNT = "1.00000000" + + +def wait_until(predicate, *, timeout=20, interval=0.25, description="condition"): + deadline = time.time() + timeout + last_error = None + while time.time() < deadline: + try: + if predicate(): + return + except Exception as err: + last_error = err + time.sleep(interval) + if last_error is not None: + raise AssertionError(f"Timed out waiting for {description}: {last_error}") from last_error + raise AssertionError(f"Timed out waiting for {description}") + + +def wait_for_wallet_balance(port, wallet_name, *, minimum_balance): + def has_balance(): + balance = Decimal(str(rpc_call(port, "getbalance", wallet=wallet_name))) + return balance >= minimum_balance + + wait_until(has_balance, timeout=30, description=f"{wallet_name} balance >= {minimum_balance}") + + +def wait_for_mempool_change(port, *, exclude_txid, timeout=20): + result = [None] + + def has_replacement(): + txids = rpc_call(port, "getrawmempool") + for t in txids: + if t != exclude_txid: + result[0] = t + return True + return False + + wait_until(has_replacement, timeout=timeout, description="replacement tx in mempool") + return result[0] + + +def wait_for_wallet_tx(port, wallet_name, txid, *, timeout=20): + result = {} + + def has_transaction(): + nonlocal result + result = rpc_call(port, "gettransaction", [txid], wallet=wallet_name) + return result.get("txid") == txid + + wait_until( + has_transaction, + timeout=timeout, + description=f"{wallet_name} transaction {txid} visible over RPC", + ) + return result + + +def wait_for_gui_object(gui, object_name, *, timeout=20, interval=0.25): + def exists(): + return any( + obj.get("objectName") == object_name + for obj in gui.list_objects() + ) + + wait_until( + exists, + timeout=timeout, + interval=interval, + description=f"{object_name} exists in QML tree", + ) + + +def ensure_activity_tab_selected(gui, *, timeout=10, attempts=3): + for attempt in range(1, attempts + 1): + if gui.get_property("walletActivityTabButton", "checked"): + return + + gui.click("walletActivityTabButton") + try: + gui.wait_for_property("walletActivityTabButton", "checked", True, timeout_ms=int(timeout * 1000)) + return + except Exception: + if attempt == attempts: + raise + gui.settle() + + raise AssertionError("Failed to select the Activity tab") + + +def wait_for_speedup_overlay_ready(gui, *, timeout=10, interval=0.25): + error_text = [""] + + def ready_or_failed(): + if gui.get_property("updateTransactionButton", "enabled"): + return True + + try: + failure_visible = gui.get_property("speedUpErrorText", "visible") + except Exception: + failure_visible = False + if failure_visible: + error_text[0] = gui.get_text("speedUpErrorText").strip() + return True + + return False + + wait_until( + ready_or_failed, + timeout=timeout, + interval=interval, + description="speed up overlay ready or failed", + ) + + if gui.get_property("updateTransactionButton", "enabled"): + return + + raise AssertionError(f"Speed up overlay failed: {error_text[0] or 'No error text exposed.'}") + + +def run_test(): + harness = WalletFlowHarness("qml_test_wallet_rbf", port_offset=70) + gui = None + try: + print("[rbf] starting source node") + harness.start_source_node() + rpc_call(harness.source_rpc_port, "createwallet", {"wallet_name": RECEIVER_WALLET_NAME}) + receiver_address = rpc_call(harness.source_rpc_port, "getnewaddress", wallet=RECEIVER_WALLET_NAME) + + print("[rbf] starting gui") + harness.start_gui() + gui = harness.driver + gui.wait_for_property("walletBadge", "loading", False, timeout_ms=30000) + gui.wait_for_property("walletBadge", "visible", True, timeout_ms=10000) + + rpc_call(harness.gui_rpc_port, "createwallet", {"wallet_name": GUI_WALLET_NAME}) + gui.wait_for_property("walletBadge", "text", GUI_WALLET_NAME, timeout_ms=20000) + gui.wait_for_property("walletBadge", "noWalletLoaded", False, timeout_ms=10000) + + print("[rbf] funding wallet") + mining_address = rpc_call(harness.gui_rpc_port, "getnewaddress", wallet=GUI_WALLET_NAME) + rpc_call(harness.gui_rpc_port, "generatetoaddress", [101, mining_address]) + wait_for_wallet_balance(harness.gui_rpc_port, GUI_WALLET_NAME, minimum_balance=Decimal("50")) + + print("[rbf] sending initial transaction") + txid = rpc_call( + harness.gui_rpc_port, + "sendtoaddress", + [receiver_address, SEND_AMOUNT], + wallet=GUI_WALLET_NAME, + ) + print(f"[rbf] sent txid: {txid}") + wait_for_wallet_tx(harness.gui_rpc_port, GUI_WALLET_NAME, txid) + + print("[rbf] navigating to activity tab") + ensure_activity_tab_selected(gui) + gui.settle() + + print("[rbf] waiting for unconfirmed tx in activity list") + activity_item_name = f"activityItem_{txid}" + try: + wait_for_gui_object(gui, activity_item_name, timeout=15) + gui.wait_for_property(activity_item_name, "visible", True, timeout_ms=5000) + except Exception: + print("[rbf] activity item not found, listing all named objects:") + for obj in gui.list_objects(): + name = obj.get("objectName", "") + if name: + print(f" {name} ({obj.get('className', '')})") + raise + gui.click(activity_item_name) + gui.settle() + + print("[rbf] verifying speed up banner") + wait_for_gui_object(gui, "speedUpBanner", timeout=10) + wait_for_gui_object(gui, "speedUpBannerPrimaryButton", timeout=10) + + print("[rbf] opening speed up overlay") + gui.click("speedUpBannerPrimaryButton") + gui.wait_for_property("speedUpOverlay", "opened", True, timeout_ms=10000) + gui.settle() + wait_for_speedup_overlay_ready(gui, timeout=10) + + print("[rbf] confirming bump") + gui.click("updateTransactionButton") + gui.settle() + + print("[rbf] waiting for replacement tx") + new_txid = wait_for_mempool_change(harness.gui_rpc_port, exclude_txid=txid) + assert new_txid != txid, f"Expected different txid, got same: {txid}" + print(f"[rbf] replacement txid: {new_txid}") + + print("[rbf] verifying original tx conflict state") + original_tx = rpc_call(harness.gui_rpc_port, "gettransaction", [txid], wallet=GUI_WALLET_NAME) + wallet_conflicts = original_tx.get("walletconflicts", []) + assert new_txid in wallet_conflicts, ( + f"Expected {new_txid} in walletconflicts, got {wallet_conflicts}" + ) + + print("[rbf] mining replacement tx") + rpc_call(harness.gui_rpc_port, "generatetoaddress", [1, mining_address]) + wait_until( + lambda: rpc_call( + harness.gui_rpc_port, "gettransaction", [new_txid], wallet=GUI_WALLET_NAME + )["confirmations"] >= 1, + timeout=20, + description="replacement tx confirmed", + ) + + print("[rbf] verifying confirmed tx is not bumpable") + try: + rpc_call(harness.gui_rpc_port, "bumpfee", [new_txid], wallet=GUI_WALLET_NAME) + assert False, "bumpfee on confirmed tx should have raised an error" + except RuntimeError as rpc_err: + message = str(rpc_err).lower() + accepted_reasons = ("confirmed", "cannot bump", "already spent") + assert any(reason in message for reason in accepted_reasons), f"Unexpected error: {rpc_err}" + print(f"[rbf] confirmed tx correctly rejected: {rpc_err}") + + print("[rbf] ALL TESTS PASSED") + return 0 + + except Exception as err: + print(f"\nFAILED: {err}", file=sys.stderr) + import traceback + traceback.print_exc() + if gui is not None: + try: + dump_qml_tree(gui) + except Exception: + pass + gui_output = harness.process_output(harness.gui_process) + if gui_output: + print("\n--- GUI process output ---", file=sys.stderr) + print(gui_output, file=sys.stderr) + return 1 + finally: + harness.stop() + + +if __name__ == "__main__": + sys.exit(run_test()) diff --git a/test/functional/qml_wallet_test_lib.py b/test/functional/qml_wallet_test_lib.py index fed967d22c..607eb2b040 100644 --- a/test/functional/qml_wallet_test_lib.py +++ b/test/functional/qml_wallet_test_lib.py @@ -123,6 +123,7 @@ def write_datadir(datadir, rpc_port, p2p_port, extra_lines=None): conf.write("connect=0\n") conf.write("listen=0\n") conf.write("shrinkdebugfile=0\n") + conf.write("fallbackfee=0.0001\n") if extra_lines: for line in extra_lines: conf.write(f"{line}\n") From 3268d3e8b11dca0c384ec66a4356251cc4995b8d Mon Sep 17 00:00:00 2001 From: pseudoramdom Date: Sat, 25 Apr 2026 21:40:51 -0700 Subject: [PATCH 4/4] qml: Dim conflicted transactions in activity list --- qml/components/InfoBanner.qml | 2 +- qml/pages/wallet/Activity.qml | 10 +++++++--- qml/pages/wallet/ActivityDetails.qml | 2 ++ qml/pages/wallet/SpeedUpOverlay.qml | 24 +++++------------------- 4 files changed, 15 insertions(+), 23 deletions(-) diff --git a/qml/components/InfoBanner.qml b/qml/components/InfoBanner.qml index 0442bac8f3..899a5627a9 100644 --- a/qml/components/InfoBanner.qml +++ b/qml/components/InfoBanner.qml @@ -28,7 +28,7 @@ Rectangle { signal dismissClicked() radius: 10 - color: Qt.rgba(Theme.color.blue.r, Theme.color.blue.g, Theme.color.blue.b, 0.3) + color: Qt.rgba(Theme.color.blue.r, Theme.color.blue.g, Theme.color.blue.b, 0.2) implicitHeight: contentLoader.item ? contentLoader.item.height + 60 : 60 Loader { diff --git a/qml/pages/wallet/Activity.qml b/qml/pages/wallet/Activity.qml index f5001581d3..71a50f022c 100644 --- a/qml/pages/wallet/Activity.qml +++ b/qml/pages/wallet/Activity.qml @@ -14,9 +14,13 @@ PageStack { id: stackView function navigateToTransaction(txid) { - if (!walletController.selectedWallet) return + if (!walletController.selectedWallet) + return + var details = walletController.selectedWallet.activityListModel.transactionDetails(txid) - if (Object.keys(details).length === 0) return + if (Object.keys(details).length === 0) + return + var page = stackView.push("ActivityDetails.qml", details) page.showTransaction.connect(stackView.navigateToTransaction) } @@ -116,7 +120,7 @@ PageStack { cursorShape: Qt.PointingHandCursor } - opacity: delegate.replacedByTxid !== "" ? 0.4 : 1.0 + opacity: (delegate.replacedByTxid !== "" || delegate.status === Transaction.Conflicted) ? 0.4 : 1.0 onClicked: { var page = stackView.push(detailsPage) diff --git a/qml/pages/wallet/ActivityDetails.qml b/qml/pages/wallet/ActivityDetails.qml index f61802e066..7ed27b90cf 100644 --- a/qml/pages/wallet/ActivityDetails.qml +++ b/qml/pages/wallet/ActivityDetails.qml @@ -202,7 +202,9 @@ Page { InfoBanner { objectName: "speedUpBanner" visible: root.canBump + Layout.alignment: Qt.AlignHCenter Layout.fillWidth: true + Layout.maximumWidth: 600 Layout.topMargin: 20 bannerLayout: InfoBanner.Layout.Vertical message: qsTr("This transaction is still unconfirmed. You can speed it up by increasing the fee.") diff --git a/qml/pages/wallet/SpeedUpOverlay.qml b/qml/pages/wallet/SpeedUpOverlay.qml index 02c1747527..7de19ee260 100644 --- a/qml/pages/wallet/SpeedUpOverlay.qml +++ b/qml/pages/wallet/SpeedUpOverlay.qml @@ -71,26 +71,12 @@ Popup { anchors.fill: parent spacing: 0 - RowLayout { + CoreText { + text: qsTr("Speed up transaction") + font.pixelSize: 21 + bold: true + horizontalAlignment: Text.AlignLeft Layout.fillWidth: true - CoreText { - text: qsTr("Speed up transaction") - font.pixelSize: 21 - bold: true - horizontalAlignment: Text.AlignLeft - Layout.fillWidth: true - } - Icon { - source: "image://images/cross" - color: Theme.color.neutral8 - size: 10 - enabled: true - padding: 0 - onClicked: root.close() - HoverHandler { - cursorShape: Qt.PointingHandCursor - } - } } CoreText {