wallet, rpc, gui: Allow users to manually rebroadcast a wallet transaction #36481

pull achow101 wants to merge 2 commits into bitcoin:master from achow101:manual-rebroadcast changing 9 files +123 −4
  1. achow101 commented at 9:41 PM on October 8, 2026: member

    From the discussion in #36403, it is clear that there needs to be a way for users to rebroadcast a transaction. Currently, the way to do so requires getting the raw transaction, getting the hex, and using sendrawtransaction. This PR adds rebroadcastwallettx RPC that takes a txid to attempt to rebroadcast that single transaction. An action is also added to the GUI menu for each transaction to do the same.

  2. DrahtBot commented at 9:41 PM on October 8, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

    The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

    <!--006a51241073e994b41acfe9ec718e94-->

    External sites

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline and AI policy for information on the review process.

    Type Reviewers
    Concept ACK polespinasa, mzumsande, murchandamus, jpk68, fametrano
    Approach ACK vasild

    If your review is incorrectly listed, please copy-paste <code>&lt;!--meta-tag:bot-skip--&gt;</code> into the comment that the bot should ignore.

    <!--174a7506f384e20aa4161008e828411d-->

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #36597 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/36597.svg"></sub> (wallet: return broadcast errors from CommitTransaction by pseudoramdom)
    • #36482 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/36482.svg"></sub> (wallet: Only broadcast resubmitted transactions that were added to the mempool by achow101)
    • #36330 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/36330.svg"></sub> (wallet: don't resubmit transactions under -privatebroadcast by instagibbs)

    If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

  3. wallet, rpc: Add rebroadcastwallettx 0a9df9bf23
  4. gui: Add rebroadcast tx menu action 648450039a
  5. achow101 force-pushed on Oct 8, 2026
  6. DrahtBot added the label CI failed on Oct 8, 2026
  7. DrahtBot commented at 10:06 PM on October 8, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task tidy: https://github.com/bitcoin/bitcoin/actions/runs/37848508934/job/113555303677</sub> <sub>LLM reason (✨ experimental): CI failed because clang-tidy flagged an ignored return value from rebroadcastTransaction() in qt/transactionview.cpp:408 (warnings treated as errors).</sub>

    <details><summary>Hints</summary>

    Try to run the tests locally, according to the documentation. However, a CI failure may still happen due to a number of reasons, for example:

    • Possibly due to a silent merge conflict (the changes in this pull request being incompatible with the current code in the target branch). If so, make sure to rebase on the latest commit of the target branch.

    • A sanitizer issue, which can only be found by compiling with the sanitizer and running the affected test.

    • An intermittent issue.

    Leave a comment here, if you need help tracking down a confusing failure.

    </details>

  8. polespinasa commented at 10:24 PM on October 8, 2026: member

    concept ACK

  9. mzumsande commented at 3:36 PM on October 9, 2026: contributor

    Concept ACK

    Whatever the solution of the existing issues with rebroadcast will be, giving the users the option to rebroadcast transactions manually is useful in all of these scenarios.

  10. murchandamus commented at 7:39 PM on October 21, 2026: member

    Concept ACK

  11. jpk68 commented at 12:27 AM on October 22, 2026: contributor

    Concept ACK

  12. fametrano commented at 11:35 AM on October 25, 2026: none

    Concept ACK

    One issue should be fixed before merge: rebroadcastwallettx fails with code -1 and an empty message when the transaction is out of the mempool and its feerate is above the wallet's -maxfeerate.

    I ran the RPC on regtest at 648450039a. Other mempool rejections keep their reason, such as "bad-txns-inputs-missingorspent" for a child whose parent expired. The empty message shows up when a user lowers -maxfeerate to 20 sat/vB and restarts without mempool.dat. Our own 50 sat/vB child of an incoming payment then stays out of the mempool, and the RPC returns {"code": -1, "message": ""}. For the same transaction, sendrawtransaction with maxfeerate=0.0002 returns -25 "Fee rate exceeds maximum configured by user (maxfeerate)".

    The cause is that Chain::broadcastTransaction returns only a bool, and BroadcastTransaction sets no error string for MAX_FEE_EXCEEDED or MAX_FEE_RATE_EXCEEDED. sendrawtransaction falls back to TransactionErrorString in that case. The wallet's other callers only log the string or drop it, so this RPC is the first to show it to a user. The same fallback inside the Chain::broadcastTransaction wrapper would fix it, and the GUI action will need that message once it shows the result. I can share the probe script.

  13. in src/interfaces/wallet.h:166 in 648450039a
     162 | @@ -163,6 +163,12 @@ class Wallet
     163 |      //! Abandon transaction.
     164 |      virtual bool abandonTransaction(const Txid& txid) = 0;
     165 |  
     166 | +    //! Return whether transaction can be abandoned.
    


    vasild commented at 10:41 AM on October 27, 2026:
        //! Return whether transaction can be broadcast.
    
  14. in src/interfaces/wallet.h:169 in 648450039a
     162 | @@ -163,6 +163,12 @@ class Wallet
     163 |      //! Abandon transaction.
     164 |      virtual bool abandonTransaction(const Txid& txid) = 0;
     165 |  
     166 | +    //! Return whether transaction can be abandoned.
     167 | +    virtual bool transactionCanBeBroadcast(const Txid& txid) = 0;
     168 | +
     169 | +    //! Abandon transaction.
    


    vasild commented at 10:41 AM on October 27, 2026:
        //! Rebroadcast a transaction.
    
  15. in src/qt/transactionview.cpp:172 in 648450039a
     168 | @@ -169,6 +169,7 @@ TransactionView::TransactionView(const PlatformStyle *platformStyle, QWidget *pa
     169 |      GUIUtil::ExceptionSafeConnect(bumpFeeAction, &QAction::triggered, this, &TransactionView::bumpFee);
     170 |      bumpFeeAction->setObjectName("bumpFeeAction");
     171 |      abandonAction = contextMenu->addAction(tr("A&bandon transaction"), this, &TransactionView::abandonTx);
     172 | +    m_rebroadcast_action = contextMenu->addAction(tr("Re&broadcast transaction"), this, &TransactionView::rebroadcastTx);
    


    vasild commented at 10:45 AM on October 27, 2026:

    What is the & in Re&broadcast? It is also in A&bandon.

  16. in src/qt/transactionview.cpp:400 in 648450039a
     393 | @@ -392,6 +394,20 @@ void TransactionView::abandonTx()
     394 |      model->wallet().abandonTransaction(hash);
     395 |  }
     396 |  
     397 | +void TransactionView::rebroadcastTx()
     398 | +{
     399 | +    if(!transactionView || !transactionView->selectionModel())
     400 | +        return;
    


    vasild commented at 10:48 AM on October 27, 2026:
        if (!transactionView || !transactionView->selectionModel())
            return;
    

    also, the return should be either on the same line as if or there should be { and }.

  17. in src/qt/transactionview.cpp:407 in 648450039a
     402 | +
     403 | +    // get the hash from the TxHashRole (QVariant / QString)
     404 | +    QString hashQStr = selection.at(0).data(TransactionTableModel::TxHashRole).toString();
     405 | +    Txid hash = Txid::FromHex(hashQStr.toStdString()).value();
     406 | +
     407 | +    // Abandon the wallet transaction over the walletModel
    


    vasild commented at 10:50 AM on October 27, 2026:
        // Rebroadcast the wallet transaction over the walletModel
    
  18. in src/wallet/wallet.cpp:1839 in 648450039a
    1838 | +    auto it = mapWallet.find(txid);
    1839 | +    if (it == mapWallet.end()) {
    1840 | +        return util::Unexpected{WalletError{WalletErrorCode::InvalidParameter, strprintf(_("%s not in wallet"), txid.ToString())}};
    1841 | +    }
    1842 | +    if (!TransactionCanBeBroadcast(it->second)) {
    1843 | +        return util::Unexpected{WalletError{WalletErrorCode::InvalidParameter, strprintf(_("%s is not eligible to be rebroadcast"), txid.ToString())}};
    


    vasild commented at 1:38 PM on October 27, 2026:

    If the wallet broadcast is disabled (-walletbroadcast=0), then the "is not eligible to be rebroadcast" will be misleading because it implies that there is something specific about this transaction that makes it not eligible.

    Consider extending TransactionCanBeBroadcast() to return the reason why the transaction can not be broadcast:

    <details> <summary>[patch] TransactionCanBeBroadcast() with a reason</summary>

    diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp
    index 29460e3ba2..dcd571b684 100644
    --- a/src/wallet/wallet.cpp
    +++ b/src/wallet/wallet.cpp
    @@ -1832,51 +1832,64 @@ util::Expected<void, WalletError> CWallet::RebroadcastSingleTransaction(const Tx
     {
         LOCK(cs_wallet);
         auto it = mapWallet.find(txid);
         if (it == mapWallet.end()) {
             return util::Unexpected{WalletError{WalletErrorCode::InvalidParameter, strprintf(_("%s not in wallet"), txid.ToString())}};
         }
    -    if (!TransactionCanBeBroadcast(it->second)) {
    -        return util::Unexpected{WalletError{WalletErrorCode::InvalidParameter, strprintf(_("%s is not eligible to be rebroadcast"), txid.ToString())}};
    -    }
         std::string err;
    +    if (!TransactionCanBeBroadcast(it->second, err)) {
    +        return util::Unexpected{WalletError{WalletErrorCode::InvalidParameter, strprintf(_("can not rebroadcast %s: %s"), txid.ToString(), err)}};
    +    }
         if (!SubmitTxMemoryPoolAndRelay(it->second, err, node::TxBroadcast::MEMPOOL_AND_BROADCAST_TO_ALL)) {
             return util::Unexpected{WalletError{WalletErrorCode::MiscError, Untranslated(err)}};
         }
         return {};
     }
     
    -bool CWallet::TransactionCanBeBroadcast(const Txid& txid) const
    +bool CWallet::TransactionCanBeBroadcast(const Txid& txid, std::string& reason) const
     {
         LOCK(cs_wallet);
         const CWalletTx* wtx = GetWalletTx(txid);
    -    return wtx && TransactionCanBeBroadcast(*wtx);
    +    return wtx && TransactionCanBeBroadcast(*wtx, reason);
     }
     
    -bool CWallet::TransactionCanBeBroadcast(const CWalletTx& wtx) const
    +bool CWallet::TransactionCanBeBroadcast(const CWalletTx& wtx, std::string& reason) const
     {
         AssertLockHeld(cs_wallet);
         // Can't relay if wallet is not broadcasting
    -    if (!GetBroadcastTransactions()) return false;
    +    if (!GetBroadcastTransactions()) {
    +        reason = "the wallet is configured to not broadcast transactions";
    +        return false;
    +    }
         // Don't relay abandoned transactions
    -    if (wtx.isAbandoned()) return false;
    +    if (wtx.isAbandoned()) {
    +        reason = "the transaction is abandoned";
    +        return false;
    +    }
         // Don't try to submit coinbase transactions. These would fail anyway but would
         // cause log spam.
    -    if (wtx.IsCoinBase()) return false;
    +    if (wtx.IsCoinBase()) {
    +        reason = "the transaction is a coinbase transaction";
    +        return false;
    +    }
         // Don't try to submit conflicted or confirmed transactions.
    -    if (GetTxDepthInMainChain(wtx) != 0) return false;
    +    if (GetTxDepthInMainChain(wtx) != 0) {
    +        reason = "the transaction is already confirmed or is conflicted";
    +        return false;
    +    }
         return true;
     }
     
     bool CWallet::SubmitTxMemoryPoolAndRelay(CWalletTx& wtx,
                                              std::string& err_string,
                                              node::TxBroadcast broadcast_method) const
     {
         AssertLockHeld(cs_wallet);
     
    -    if (!TransactionCanBeBroadcast(wtx)) return false;
    +    std::string reason;
    +    if (!TransactionCanBeBroadcast(wtx, reason)) return false;
     
         const char* what{""};
         switch (broadcast_method) {
         case node::TxBroadcast::MEMPOOL_AND_BROADCAST_TO_ALL:
             what = "to mempool and for broadcast to peers";
             break;
    diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h
    index 8d7c72f4d7..0b64d62571 100644
    --- a/src/wallet/wallet.h
    +++ b/src/wallet/wallet.h
    @@ -443,13 +443,13 @@ private:
         // Update last block processed in memory only
         void SetLastBlockProcessedInMem(int block_height, uint256 block_hash) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);
     
         //! Update mempool conflicts for TRUC sibling transactions
         void UpdateTrucSiblingConflicts(const CWalletTx& parent_wtx, const Txid& child_txid, bool add_conflict) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);
     
    -    bool TransactionCanBeBroadcast(const CWalletTx& wtx) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);
    +    bool TransactionCanBeBroadcast(const CWalletTx& wtx, std::string& reason) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);
     
     public:
         /**
          * Main wallet lock.
          * This lock protects all the fields added by CWallet.
          */
    @@ -635,13 +635,13 @@ public:
         void transactionRemovedFromMempool(const CTransactionRef& tx, MemPoolRemovalReason reason) override;
         /** Set the next time this wallet should resend transactions to 12-36 hours from now, ~1 day on average. */
         void SetNextResend() { m_next_resend = GetDefaultNextResend(); }
         /** Return true if all conditions for periodically resending transactions are met. */
         bool ShouldResend() const;
         void ResubmitWalletTransactions(node::TxBroadcast broadcast_method, bool force);
    -    bool TransactionCanBeBroadcast(const Txid& txid) const;
    +    bool TransactionCanBeBroadcast(const Txid& txid, std::string& reason) const;
         util::Expected<void, WalletError> RebroadcastSingleTransaction(const Txid& txid);
     
         OutputType TransactionChangeType(const std::optional<OutputType>& change_type, const std::vector<CRecipient>& vecSend) const;
     
         /** Fetch the inputs and sign with SIGHASH_ALL. */
         bool SignTransaction(CMutableTransaction& tx) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);
    diff --git a/src/wallet/interfaces.cpp b/src/wallet/interfaces.cpp
    index 78139f635d..1759680b02 100644
    --- a/src/wallet/interfaces.cpp
    +++ b/src/wallet/interfaces.cpp
    @@ -283,13 +283,17 @@ public:
         bool transactionCanBeAbandoned(const Txid& txid) override { return m_wallet->TransactionCanBeAbandoned(txid); }
         bool abandonTransaction(const Txid& txid) override
         {
             LOCK(m_wallet->cs_wallet);
             return m_wallet->AbandonTransaction(txid);
         }
    -    bool transactionCanBeBroadcast(const Txid& txid) override { return m_wallet->TransactionCanBeBroadcast(txid); }
    +    bool transactionCanBeBroadcast(const Txid& txid) override
    +    {
    +        std::string reason;
    +        return m_wallet->TransactionCanBeBroadcast(txid, reason);
    +    }
         util::Expected<void, WalletError> rebroadcastTransaction(const Txid& txid) override
         {
             LOCK(m_wallet->cs_wallet);
             return m_wallet->RebroadcastSingleTransaction(txid);
         }
         bool transactionCanBeBumped(const Txid& txid) override
    

    </details>

  19. in src/wallet/rpc/transactions.cpp:1115 in 648450039a
    1110 | +            HelpExampleRpc("rebroadcastwallettx", "\"txid\"")
    1111 | +        },
    1112 | +        [](const RPCMethod& self, const JSONRPCRequest& request) -> UniValue
    1113 | +        {
    1114 | +            std::shared_ptr<CWallet> const wallet = GetWalletForJSONRPCRequest(request);
    1115 | +            if (!wallet) return UniValue::VNULL;
    


    vasild commented at 1:43 PM on October 27, 2026:

    Should we not throw here? UniValue::VNULL is for "If rebroadcast is successfully initiated".

    -            if (!wallet) return UniValue::VNULL;
    +            if (!wallet) {
    +                throw JSONRPCError(RPC_WALLET_ERROR, "no wallet");
    +            }
    
  20. in src/wallet/rpc/transactions.cpp:1103 in 648450039a
    1094 | @@ -1095,4 +1095,32 @@ RPCMethod abortrescan()
    1095 |  },
    1096 |      };
    1097 |  }
    1098 | +
    1099 | +RPCMethod rebroadcastwallettx()
    1100 | +{
    1101 | +    return RPCMethod{"rebroadcastwallettx",
    1102 | +        "Rebroadcast the specified wallet transaction.\n"
    1103 | +        "Note that rebroadcasting a transaction may reveal that your node is involved as either a sender or receiver in the transaction.",
    


    vasild commented at 1:45 PM on October 27, 2026:

    I think it would be useful to add the sentence Use the listtransactions RPC to see the wallet transactions here.

  21. vasild commented at 2:03 PM on October 27, 2026: contributor

    Approach ACK 648450039ab1c93f5e0001e0829f094ca41d2bae


github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-10-30 09:40 UTC