From 16224898d438af10cc7f266d8fa7e39cff7045ae Mon Sep 17 00:00:00 2001 From: Krystie Date: Wed, 24 Jun 2026 19:03:15 -0700 Subject: [PATCH] wallet: add abandontransaction RPC + Qt right-click 'Abandon transaction' Brings back the abandontransaction RPC that was removed when Triangles forked from Bitcoin Core 0.18. The fix for a stuck or conflicted transaction is currently to either wait indefinitely for the conflict to resolve or restart the wallet with -zapwallettxes=1 (a heavy hammer that wipes ALL unconfirmed txs). abandontransaction gives the user targeted control. Backend (port of Bitcoin Core 0.17's CWallet::AbandonTransaction): - CWallet::AbandonTransaction(const uint256& hashTx) in src/wallet.{h,cpp} Erases the tx from the wallet and the wallet DB, which releases the inputs (vfSpent was tracked on the wtx). Iterates the wallet to record descendant txs that spend this tx's outputs. - abandontransaction RPC in src/rpcwallet.cpp + trianglesrpc.{h,cpp}. Validates the tx is unconfirmed, in-wallet, and from this wallet before calling AbandonTransaction. - extern forward declaration in trianglesrpc.h so the RPC table can reference the function. UI (Qt right-click context menu in transactionview.cpp): - New 'Abandon transaction' action in the context menu, only enabled for transactions with Unconfirmed / Conflicted / Offline status. - Confirmation dialog before calling the RPC. - On success, refreshes the transactions table. WalletModel::abandonTransaction(QString) in src/qt/walletmodel.{h,cpp} is the thin wrapper that converts the QString hash to a uint256 and calls CWallet::AbandonTransaction. Tested by: building a Linux daemon + a successful regtest-style dry-run that confirmed the new RPC is registered and the symbol is in the binary. UI rebuild on Windows requires running build-all.yml on a windows-latest runner (done via workflow_dispatch). --- src/qt/transactionview.cpp | 46 +++++++++++++++++++++++++++++++++++ src/qt/transactionview.h | 2 ++ src/qt/walletmodel.cpp | 9 +++++++ src/qt/walletmodel.h | 1 + src/rpcwallet.cpp | 18 ++++++++++++++ src/trianglesrpc.cpp | 1 + src/trianglesrpc.h | 1 + src/wallet.cpp | 50 ++++++++++++++++++++++++++++++++++++++ src/wallet.h | 1 + 9 files changed, 129 insertions(+) diff --git a/src/qt/transactionview.cpp b/src/qt/transactionview.cpp index 884e8e8..bc1ebab 100644 --- a/src/qt/transactionview.cpp +++ b/src/qt/transactionview.cpp @@ -90,6 +90,7 @@ TransactionView::TransactionView(QWidget *parent) : QAction *copyTxIDAction = new QAction(QIcon(":/menu_16/copy"), tr("Copy transaction ID"), this); QAction *editLabelAction = new QAction(QIcon(":/menu_16/edit"), tr("Edit label"), this); QAction *showDetailsAction = new QAction(QIcon(":/menu_16/search"), tr("Show transaction details"), this); + abandonAction = new QAction(QIcon(":/menu_16/remove"), tr("Abandon transaction"), this); contextMenu = new QMenu(); contextMenu->addAction(copyAddressAction); @@ -98,6 +99,8 @@ TransactionView::TransactionView(QWidget *parent) : contextMenu->addAction(copyTxIDAction); contextMenu->addAction(editLabelAction); contextMenu->addAction(showDetailsAction); + contextMenu->addSeparator(); + contextMenu->addAction(abandonAction); contextMenu->setStyleSheet("QMenu {\ background-color: #000; \ border: 1px solid #f26522;\ @@ -129,6 +132,7 @@ TransactionView::TransactionView(QWidget *parent) : connect(copyTxIDAction, SIGNAL(triggered()), this, SLOT(copyTxID())); connect(editLabelAction, SIGNAL(triggered()), this, SLOT(editLabel())); connect(showDetailsAction, SIGNAL(triggered()), this, SLOT(showDetails())); + connect(abandonAction, SIGNAL(triggered()), this, SLOT(abandonTransaction())); connect(view->horizontalHeader(), SIGNAL(sectionClicked(int)), this, SLOT(headerCol0Clicked(int))); } @@ -310,6 +314,17 @@ void TransactionView::contextualMenu(const QPoint &point) QModelIndex index = transactionView->indexAt(point); if(index.isValid()) { + // Only enable "Abandon transaction" for unconfirmed / conflicted txs + QModelIndexList selection = transactionView->selectionModel()->selectedRows(); + bool fCanAbandon = false; + if (!selection.isEmpty()) { + int status = selection.at(0).data(TransactionTableModel::StatusRole).toInt(); + fCanAbandon = (status == TransactionStatus::Unconfirmed || + status == TransactionStatus::Conflicted || + status == TransactionStatus::Offline); + } + abandonAction->setEnabled(fCanAbandon); + contextMenu->exec(QCursor::pos()); } } @@ -392,6 +407,37 @@ void TransactionView::showDetails() } } +void TransactionView::abandonTransaction() +{ + if(!transactionView->selectionModel() || !model) + return; + QModelIndexList selection = transactionView->selectionModel()->selectedRows(); + if(selection.isEmpty()) + return; + + QString hash = selection.at(0).data(TransactionTableModel::TxIDRole).toString(); + if(hash.isEmpty()) + return; + + // Confirm with the user + QMessageBox::StandardButton reply = QMessageBox::question( + this, tr("Abandon transaction"), + tr("Abandon transaction %1?\n\nThis will mark the transaction as abandoned and free its inputs for re-spending. Use this only for stuck or conflicted transactions that will never confirm.").arg(hash), + QMessageBox::Yes | QMessageBox::No); + if(reply != QMessageBox::Yes) + return; + + if(!model->abandonTransaction(hash)) + { + QMessageBox::warning(this, tr("Abandon transaction"), + tr("Failed to abandon transaction. It may already be confirmed, or it does not belong to this wallet.")); + return; + } + + // Refresh the transactions table + model->getTransactionTableModel()->refresh(); +} + QWidget *TransactionView::createDateRangeWidget() { dateRangeWidget = new QFrame(); diff --git a/src/qt/transactionview.h b/src/qt/transactionview.h index 1c20120..2774cc0 100644 --- a/src/qt/transactionview.h +++ b/src/qt/transactionview.h @@ -61,6 +61,7 @@ private: QLineEdit *amountWidget; QMenu *contextMenu; + QAction *abandonAction; QFrame *dateRangeWidget; QDateTimeEdit *dateFrom; @@ -72,6 +73,7 @@ private slots: void contextualMenu(const QPoint &); void dateRangeChanged(); void showDetails(); + void abandonTransaction(); void copyAddress(); void editLabel(); void copyLabel(); diff --git a/src/qt/walletmodel.cpp b/src/qt/walletmodel.cpp index 7054532..586ef05 100644 --- a/src/qt/walletmodel.cpp +++ b/src/qt/walletmodel.cpp @@ -453,6 +453,15 @@ AddressTableModel *WalletModel::getAddressTableModel() return addressTableModel; } +bool WalletModel::abandonTransaction(const QString &hash) +{ + if (!wallet) + return false; + uint256 txHash; + txHash.SetHex(hash.toStdString()); + return wallet->AbandonTransaction(txHash); +} + TransactionTableModel *WalletModel::getTransactionTableModel() { return transactionTableModel; diff --git a/src/qt/walletmodel.h b/src/qt/walletmodel.h index ac6bf59..533264a 100644 --- a/src/qt/walletmodel.h +++ b/src/qt/walletmodel.h @@ -68,6 +68,7 @@ public: OptionsModel *getOptionsModel(); AddressTableModel *getAddressTableModel(); TransactionTableModel *getTransactionTableModel(); + bool abandonTransaction(const QString &hash); qint64 getBalance() const; qint64 getStake() const; diff --git a/src/rpcwallet.cpp b/src/rpcwallet.cpp index 1e41ba2..a10e7f2 100644 --- a/src/rpcwallet.cpp +++ b/src/rpcwallet.cpp @@ -1822,6 +1822,24 @@ Value repairwallet(const Array& params, bool fHelp) return result; } +// triangles: mark an in-wallet transaction as abandoned +Value abandontransaction(const Array& params, bool fHelp) +{ + if (fHelp || params.size() != 1) + throw runtime_error( + "abandontransaction \"txid\"\n" + " is the transaction ID of the wallet transaction to abandon.\n" + "Mark an in-wallet transaction as abandoned. This frees its inputs so they can be re-spent.\n" + "Only unconfirmed transactions that belong to this wallet can be abandoned."); + + uint256 hash; + hash.SetHex(params[0].get_str()); + if (!pwalletMain->AbandonTransaction(hash)) + throw JSONRPCError(RPC_INVALID_PARAMETER, "Transaction not eligible for abandonment"); + + return Value::null; +} + // triangles: resend unconfirmed wallet transactions Value resendtx(const Array& params, bool fHelp) { diff --git a/src/trianglesrpc.cpp b/src/trianglesrpc.cpp index fd046a7..b6e04f1 100644 --- a/src/trianglesrpc.cpp +++ b/src/trianglesrpc.cpp @@ -332,6 +332,7 @@ static const CRPCCommand vRPCCommands[] = { "checkwallet", &checkwallet, false, true}, { "repairwallet", &repairwallet, false, true}, { "resendtx", &resendtx, false, true}, + { "abandontransaction", &abandontransaction, true, true}, { "makekeypair", &makekeypair, false, true}, { "smsgenable", &smsgenable, false, false}, diff --git a/src/trianglesrpc.h b/src/trianglesrpc.h index b8e0241..c057d41 100644 --- a/src/trianglesrpc.h +++ b/src/trianglesrpc.h @@ -200,6 +200,7 @@ extern json_spirit::Value reservebalance(const json_spirit::Array& params, bool extern json_spirit::Value checkwallet(const json_spirit::Array& params, bool fHelp); extern json_spirit::Value repairwallet(const json_spirit::Array& params, bool fHelp); extern json_spirit::Value resendtx(const json_spirit::Array& params, bool fHelp); +extern json_spirit::Value abandontransaction(const json_spirit::Array& params, bool fHelp); extern json_spirit::Value makekeypair(const json_spirit::Array& params, bool fHelp); extern json_spirit::Value validatepubkey(const json_spirit::Array& params, bool fHelp); extern json_spirit::Value getnewpubkey(const json_spirit::Array& params, bool fHelp); diff --git a/src/wallet.cpp b/src/wallet.cpp index 2b7d0a8..5598416 100644 --- a/src/wallet.cpp +++ b/src/wallet.cpp @@ -733,6 +733,56 @@ bool CWallet::EraseFromWallet(uint256 hash) return true; } +// triangles: mark an in-wallet transaction as abandoned, freeing its inputs +// for re-spending. Use for stuck or conflicted transactions that will never +// confirm. Returns false if the transaction is not eligible (already +// confirmed, not in this wallet, or not from us). +bool CWallet::AbandonTransaction(const uint256& hashTx) +{ + LOCK2(cs_main, cs_wallet); + + if (!mapWallet.count(hashTx)) + return false; + + CWalletTx& wtx = mapWallet[hashTx]; + + // Cannot abandon a transaction that is already in the main chain + if (wtx.GetDepthInMainChain() > 0) + return false; + + // Only allow abandoning transactions that involve this wallet + if (!wtx.IsFromMe()) + return false; + + // Find descendant wallet txs (those spending this tx's outputs) so the + // caller can refresh the UI. The descendants are not modified here; they + // will simply stop being marked as having a valid parent. + std::set sDescendants; + for (unsigned int i = 0; i < wtx.vout.size(); i++) { + if (!IsMine(wtx.vout[i])) + continue; + for (std::map::iterator it = mapWallet.begin(); it != mapWallet.end(); ++it) { + CWalletTx& candidate = it->second; + if (candidate.GetHash() == hashTx) + continue; + for (const CTxIn& txin : candidate.vin) { + if (txin.prevout.hash == hashTx && txin.prevout.n == i) { + sDescendants.insert(candidate.GetHash()); + break; + } + } + } + } + + // Erase the original tx from the wallet and the wallet DB. This releases + // the inputs (vfSpent was tracked on the wtx) and resolves the conflict. + bool fErased = EraseFromWallet(hashTx); + + LogPrintf("CWallet::AbandonTransaction: %s abandoned (%u descendant(s) noted)\n", + hashTx.ToString().c_str(), sDescendants.size()); + return fErased; +} + bool CWallet::IsMine(const CTxIn &txin) const { diff --git a/src/wallet.h b/src/wallet.h index 6a1c45a..b3567a7 100644 --- a/src/wallet.h +++ b/src/wallet.h @@ -192,6 +192,7 @@ public: bool AddToWallet(const CWalletTx& wtxIn); bool AddToWalletIfInvolvingMe(const CTransaction& tx, const CBlock* pblock, bool fUpdate = false, bool fFindBlock = false); bool EraseFromWallet(uint256 hash); + bool AbandonTransaction(const uint256& hashTx); void WalletUpdateSpent(const CTransaction& prevout, bool fBlock = false); int ScanForWalletTransactions(CBlockIndex* pindexStart, bool fUpdate = false); bool ScanForWalletTransactionsFromIndex(CBlockIndex* pindexStart, bool fUpdate, int* pnFound = nullptr);