Skip to content

net: keep finished private broadcast txs in memory, rebroadcast if evicted from mempool#34707

Open
andrewtoth wants to merge 9 commits intobitcoin:masterfrom
andrewtoth:private_broadcast_store
Open

net: keep finished private broadcast txs in memory, rebroadcast if evicted from mempool#34707
andrewtoth wants to merge 9 commits intobitcoin:masterfrom
andrewtoth:private_broadcast_store

Conversation

@andrewtoth
Copy link
Contributor

@andrewtoth andrewtoth commented Mar 2, 2026

Follow up from #34329.

Private broadcast transaction data is removed from memory when either 1) the tx is received back from another peer and inserted into our mempool or 2) the tx is no longer acceptable to our mempool.

We want to persist this data for a number of reasons. See #30471.

This PR changes the behavior of the private broadcast queue to keep received and mempool-rejected txs in memory. The peer's address we received the tx from and the time, or the mempool rejection reason is now also returned via getprivatebroadcastinfo RPC.

We store the txs mempool state as either IN_MEMPOOL, ACCEPTABLE, or REJECTED. Only txs that are currently not in the mempool but are acceptable will be picked for broadcast and returned as pending transactions. This maintains the current behavior of stopping broadcasting when these conditions are met.

When reattempting to broadcast stale private broadcast txs, every tx whether pending or not is checked for mempool acceptance. This has the added benefit of rebroadcasting previously received txs that have since been evicted from the mempool.

This is designed to facilitate follow up work including:

@DrahtBot DrahtBot added the P2P label Mar 2, 2026
@DrahtBot
Copy link
Contributor

DrahtBot commented Mar 2, 2026

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

Reviews

See the guideline for information on the review process.

Type Reviewers
Stale ACK optout21

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

Conflicts

Reviewers, this pull request conflicts with the following ones:

  • #34271 (net_processing: make m_tx_for_private_broadcast optional by vasild)

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.

LLM Linter (✨ experimental)

Possible typos and grammar issues:

  • receive -> received [The log message "Checking getprivatebroadcastinfo reports receive metadata after it is received back" uses "receive" where the past participle "received" is needed for grammatical clarity.]

2026-03-12 00:12:34

@andrewtoth andrewtoth force-pushed the private_broadcast_store branch from a88810a to afca844 Compare March 2, 2026 02:03
@DrahtBot
Copy link
Contributor

DrahtBot commented Mar 2, 2026

🚧 At least one of the CI tasks failed.
Task macOS native: https://github.com/bitcoin/bitcoin/actions/runs/22558096885/job/65339168667
LLM reason (✨ experimental): private_broadcast_tests failed with an assertion error in netaddress.cpp (ToStringAddr), causing the test subprocess to abort.

Hints

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.

Copy link
Contributor

@optout21 optout21 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ConceptACK afca844

Pre-review, left some minor comments.
I general conceptual point is raised in separate comment (main-thread).

const auto it{m_transactions.find(tx)};
if (it == m_transactions.end()) return;
it->second.mempool_rejection_reason = std::move(rejection_reason);
if (!it->second.mempool_rejection_reason.has_value()) {
Copy link
Contributor

@optout21 optout21 Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find the dependency between rejection_reason and received_from confusing here. If the TX can be accepted in the mempool, the received from info is cleared. Could you elaborate on the why?

Update: I found it described in the header comment, but I still don't understand fully (may be my problem).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated this logic and added a clarifying comment.
It only clears the received from info now if there is a transition from mempool rejected -> mempool acceptable. This means the tx has been evicted from our mempool for some reason that does not cause a conflict with it. In this case, it is like we never received the tx from a peer. It would be confusing to have a state where we received it but it is not in our mempool and not conflicted or confirmed. So, we clear received state and try the broadcasting flow again.

Now we also pick txs if they have are acceptable to our mempool and have no received from state. So, it is necessary to clear this data to ever have this tx picked for private broadcast again.

Copy link
Contributor Author

@andrewtoth andrewtoth Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about this some more, and it seems it was as confusing as it was because I was trying to use a single piece of state to represent two different possible states. If mempool_rejection_reason was not nullopt it could mean either the tx is not in the mempool and is not acceptable, or is currently in the mempool.

I split this out into an enum with 3 states:
IN_MEMPOOL,
ACCEPTABLE,
REJECTED.

Now only if we are REJECTED will we store a reason string.

This simplifies the logic since only ACCEPTABLE txs will be picked for broadcast and returned as pending.

@optout21
Copy link
Contributor

optout21 commented Mar 6, 2026

A more important conceptual point I'd like to raise:
this work is the precursor of features that are related to locally-originated transactions, and not private broadcast. I think that it is important to clarify this and use the right nomenclature at this early stage.

As I see:

  • there where wishes/proposals related to local-originated transaction handling, private broadcast being one of them
  • private broadcast for locally-originating txs was implemented (🔥), and it introduced a short-lived queue needed for private broadcast
  • other features are starting to be implemented on top of the private broadcast queue, but logically they are not strictly related to the private nature of the broadcast, but the locally-generated property of it.
  • a good test is to answer the question: would this feature make sense even if the broadcast was not private, but 'regular' broadcast? If the answer is yes or likely yes, than the feaure is not related to the private broadcast, and its naming should not be based on it.
  • it should be even possible to decouple private broadcast from other local-originating features (though it makes sense to use private bc always).
  • With the current direction of things I even propose that the new RPC's names (getprivatebroadcastinfo) should not be based on the 'private' feature, but 'local tx' . Also, if the mentioned features are being considered, the name of the queue could be reconsidered at this point.

In short, for the newly considered features I consider the "locally-originated TX" to be more relevant than the "private" nature of the broadcast, and suggest using it in namings.

(fyi @vasild)

Copy link
Contributor

@vasild vasild left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed up to and including fa857a9 net: track received peer and mempool status in private broadcast.

@optout21, good points. Consider also #34533 and #3828. In the light of that, a sensitive transaction may not have originated locally. So "local" may not be the best term to use for these.

});
}

std::vector<CTransactionRef> PrivateBroadcast::GetStale() const
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetStale() needs to be changed to omit the concluded (finished) transactions.

Copy link
Contributor Author

@andrewtoth andrewtoth Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking more about this and I don't see a way to have "finished" transactions.

If a tx is acceptable to our mempool, it is not yet finished because it has not been received.
If a tx exists in our mempool, it is not yet finished because it has not been mined and may be evicted.
If a tx is not acceptable to our mempool, it or a conflicting tx may have been mined. However, it may also be the case that its parent tx has been evicted from our mempool as well and not yet mined. Either way, checking the confirmed utxo set will also show this tx as unacceptable, when it will be acceptable if the parent enters the mempool again. This tx is not yet finished since it may be acceptable to our mempool again if the parent returns.

So, is there a heuristic we can use to determine if a tx is concluded (finished)? I don't see one. We need to keep txs in the queue indefinitely.
One way is to have an expiry on txs which you suggested in #34322, but I believe that is out of scope for this PR.

@andrewtoth andrewtoth force-pushed the private_broadcast_store branch 2 times, most recently from 2c3c80d to 9f58d9a Compare March 11, 2026 01:40
@andrewtoth
Copy link
Contributor Author

Thank you for your reviews @optout21 and @vasild.

  • Add no longer clears received state, so it is a simpler change. However, it does not rebroadcast txs if they are already received and sent again with sendrawtransaction. Removed unit test and functional test that covered this.
  • Only clear received state if transitioning from mempool rejected -> mempool acceptable. This happens if the tx is evicted but is not conflicted. In this case it is no longer "received" and we want to start the broadcast flow again.
  • Check if a tx already exists in mempool in ReattemptPrivateBroadcast before checking mempool acceptance.

Also addressed several other refactor and style changes, and split up and reordered commits for easier review.

git range-diff 9cad97f6cdf1bd660732cd10e844a6a7e0771ea0..afca84489c26f920d842f34d00f8418349255a65 eed316189376d72fc959e8d2e45cafef279f77da..9f58d9a1691b10341784e13ae32549bb0244bbda

I consider the "locally-originated TX" to be more relevant than the "private" nature of the broadcast, and suggest using it in namings.

@optout21 that's an interesting point. I do think it is out of scope for this PR though. Could be raised independently.

@andrewtoth andrewtoth force-pushed the private_broadcast_store branch from 9f58d9a to c9b19d7 Compare March 11, 2026 13:39
@andrewtoth
Copy link
Contributor Author

Updated test to fix a bad rebase. Also, addressed feedback to make rebroadcast logic more clear #34707 (comment).

git diff 9f58d9a1691b10341784e13ae32549bb0244bbda..c9b19d76dd336b2342d6d766b408410e1b93107b

@andrewtoth andrewtoth force-pushed the private_broadcast_store branch from c9b19d7 to 6ecbf18 Compare March 11, 2026 15:12
This is a non-functional change.
Wrapping the sent_to vector in a struct lets us add more fields to each tx.
Introduce MempoolState enum (IN_MEMPOOL, ACCEPTABLE, REJECTED) and
UpdateMempoolState method in PrivateBroadcast.

Add GetPendingTransactions to filter by ACCEPTABLE state. Only pick
pending txs for sending. Don't remove txs when not acceptable to
mempool in stale check; instead track their rejection state.
@andrewtoth andrewtoth force-pushed the private_broadcast_store branch from 6ecbf18 to 911d4eb Compare March 11, 2026 20:58
Introduce Received method in PrivateBroadcast to record which peer
relayed the transaction back to us. The transaction stays in the
queue instead of being removed, preserving broadcast metadata.
@andrewtoth andrewtoth force-pushed the private_broadcast_store branch from 911d4eb to bd31f73 Compare March 12, 2026 00:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants