Skip to content

Introduce IntentTracker - #257

Closed
evanlinjin wants to merge 7 commits into
bitcoindevkit:masterfrom
evanlinjin:feature/broadcast-queue
Closed

Introduce IntentTracker#257
evanlinjin wants to merge 7 commits into
bitcoindevkit:masterfrom
evanlinjin:feature/broadcast-queue

Conversation

@evanlinjin

@evanlinjinevanlinjin commented Jun 6, 2025

Copy link
Copy Markdown
Member

Fixes#166
Fixes#40
Fixed#295
Replaces #220

Description

Allows callers to spend from unbroadcasted transactions.

Notes to the reviewers

I think I may have done some overthinking for the BroadcastQueue implementation. This is the current implementation:

  • Wallet::add_tx_to_broadcast_queue will also remove conflicts (of the tx being inserted) from the broadcast queue.
  • Wallet::remove_tx_from_broadcast_queue will also remove descendants of the tx being removed.

However, I’m not convinced this feature is necessary, and it could lead to inconsistent behavior if callers sometimes use the BroadcastQueue, bypass it to broadcast transactions directly, or if multiple instances of the same wallet broadcast concurrently. In such cases—when intermediate transactions are missing from the queue—the logic described above will fail.

There is an argument for RBF, however, why would you need to RBF unbroadcasted transactions? It's better to empty the queue and start again.

Changelog notice

Checklists

To Get Out of Draft Status:

  • Have a section in the struct-level (Wallet) docs that explains the broadcast queue.
  • Better docs for each new method added.
  • Example: Wallet with single UTXO. Create x number of transactions sequentially. Broadcast all in one go. Sync.
  • Test persistence (sqlite).
  • More tests.

To Get This Merged:

All Submissions:

  • I've signed all my commits
  • I followed the contribution guidelines
  • I ran cargo +nightly fmt and cargo clippy before committing

New Features:

  • I've added tests for the new feature
  • I've added docs for the new feature

Bugfixes:

  • This pull request breaks the existing API
  • I've added tests to reproduce the issue which are now passing
  • I'm linking the issue being fixed by this PR

@coveralls

coveralls commented Jun 6, 2025

Copy link
Copy Markdown

Pull Request Test Coverage Report for Build 15943002368

Warning: This coverage report may be inaccurate.

This pull request's base commit is no longer the HEAD commit of its target branch. This means it includes changes from outside the original pull request, including, potentially, unrelated coverage changes.

Details

  • 213 of 632(33.7%) changed or added relevant lines in 5 files are covered.
  • 14 unchanged lines in 5 files lost coverage.
  • Overall coverage decreased (-4.9%) to 80.602%

Changes Missing CoverageCovered LinesChanged/Added Lines%
wallet/src/wallet/tx_builder.rs21020.0%
wallet/src/wallet/changeset.rs254160.98%
wallet/src/wallet/mod.rs13522260.81%
wallet/src/wallet/intent_tracker.rs4935713.73%
Files with Coverage ReductionNew Missed Lines%
wallet/src/descriptor/dsl.rs195.34%
wallet/src/wallet/changeset.rs279.44%
wallet/src/descriptor/policy.rs379.07%
wallet/src/descriptor/template.rs498.04%
wallet/src/wallet/mod.rs478.06%
TotalsCoverage Status
Change from base Build 15476130196:-4.9%
Covered Lines:6644
Relevant Lines:8243

💛 - Coveralls

@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch 2 times, most recently from 808bbdf to 75bc892CompareJune 6, 2025 10:27
@evanlinjinevanlinjin self-assigned this Jun 7, 2025
@notmandatorynotmandatory moved this to In Progress in BDK WalletJun 7, 2025
@notmandatorynotmandatory added the new feature New feature or request label Jun 7, 2025
Comment threadwallet/src/types.rs
/// The position of the output in the blockchain.
pub chain_position: ChainPosition<ConfirmationBlockTime>,
/// Whether this output exists in a transaction that is yet to be broadcasted.
pub needs_broadcast: bool,

@nymiusnymiusJun 8, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe an enum state field with something like: UNSPENT, ON_QUEUE, SPENT, BROADCASTED will avoid keep adding new boolean fields here, and provide a better path for update on future occasions, taking advantage of non exhaustive patters. is_spent could be marked for deprecation and be used along the new field in the meantime.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I like this idea if done as a non-exhaustive enum to help reduce future API breaking changes. If we include a "LOCKED" variant could this also support #259?

@nymiusnymius left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

However, I’m not convinced this feature is necessary, and it could lead to inconsistent behavior if callers sometimes use the BroadcastQueue, bypass it to broadcast transactions directly, or if multiple instances of the same wallet broadcast concurrently. In such cases—when intermediate transactions are missing from the queue—the logic described above will fail.

Can we enforce or support BroadcastQueue as the only way to broadcast transactions in bdk_wallet?
A user bypassing this mechanism should be considered? Are there reasons to not doing it?
Why would you keep broadcasting tx outside of the queue when you have an unbroadcasted tx in the queue?

I like the approach, and think is easy to reason about. Maybe we could leave the door open to implement other broadcast policies.
IMHO, the BroadcastQueue "profile" should ensure internal consistency, so I don't think is over engineered.

Why would you need to RBF unbroadcasted transactions?

Not a use case that I've needed, but maybe share multiple conflicting transactions offline looking for fee optimization in different scenarios.

@nymiusnymius mentioned this pull request Jun 8, 2025
7 tasks
@thunderbiscuit

thunderbiscuit commented Jun 9, 2025

Copy link
Copy Markdown
Member

Concept ACK. I like the idea of the queue.

I took a look at the diff and here are some thoughts/questions, pardon me if some of them would have been answered by doing a code deep dive, I just know you wanted early feedback so decided to get moving on it sooner than later.

  • Simple is good in my mind. If one of the requirements of the queue is that it's always internally valid and could in theory be broadcast all at once in one go, that's an easier mental model than allowing conflicts in the queue.
  • If the queue can actually have conflicts, it's less of a queue and more of a "bag" of transactions. Again less easy to reason about, and now the naming is misleading from the point of view of the users (I mean it's not that bad, I just mean it's not as neat/pure)
  • I like that the queue purges itself automatically on syncs.
  • I am potentially drawn to the idea from @nymius that all transactions could need to go in the queue first to then be broadcast. I wonder if that's an elegant way to force clean setups and handle the fact that a ton of wallets probably don't do costly sync every time they build transactions. That way the queue would always be aware of what has been broadcast. Does that complicate things too much? It would just be important that the library not have any footguns that would for example have you forget about a tx in the queue, persist it, then weeks later you just do Wallet::broadcast_queue and bam you just sent more than you wanted.

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for tackling this, took a first look.

I think I may have done some overthinking for the BroadcastQueue implementation. This is the current implementation:

Do we know how Core handles these things?

Also, when do we expect this queue to be processed? Would this happen manually or automatically in intervals?

Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
queue: VecDeque<Txid>,

/// Enforces that we do not have duplicates in `queue`.
dedup: HashSet<Txid>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I wonder if it's worth having this separate set? How many unbroadcasted transactions are we expecting at any given time? Maybe it would just be quicker to simply iterate over the queue itself, also saving the heap allocations/memory footprint?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

That is a good point. Maybe premature optimization here.

Comment threadwallet/src/types.rs
/// The position of the output in the blockchain.
pub chain_position: ChainPosition<ConfirmationBlockTime>,
/// Whether this output exists in a transaction that is yet to be broadcasted.
pub needs_broadcast: bool,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When do we expect this to be set/unset exactly? I guess it can only be unset once the transaction in question reaches threshold confirmations?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

That is a good point, and it shows the limitations of the BroadcastQueue concept. In fact, I did some further thinking on this and the BroadcastQueue should really be an IntentTracker and should track txs even if they are "network canonical".

There should be a method such as .tracked_txs_which_are_not_network_canonical (better name needed) so that the caller can decide to either replace the tx, or explicitly forget about it. There are caveats to doing both since we don't want to create a sub-graph where intended payments are duplicated - BDK should handle these situations properly, or provide the required information so that the caller can make a safe decision.

@notmandatory

notmandatory commented Jun 12, 2025

Copy link
Copy Markdown
Member

Do we know how Core handles these things?

@tnull do you mean how does the Core wallet handle un-broadcasted Tx and building new Tx on those un-broadcasted Tx outputs? As far as I know there are no features in the Core wallet for this beyond you the user holding on to your signed and un-broadcast Tx and manually building on those Tx outputs with the commands:

  1. createrawtransaction
  2. signrawtransactionwithwallet
  3. when you're ready to broadcast any of these Tx sendrawtransaction

@notmandatorynotmandatory left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Overall looks like a powerful new feature, I only have minor comments. Once you feel the API is ready I'd like to have a live chat to review it with L2 users like @tnull and @stevenroose to validate it meets their use cases.

Comment threadwallet/src/types.rs
/// The position of the output in the blockchain.
pub chain_position: ChainPosition<ConfirmationBlockTime>,
/// Whether this output exists in a transaction that is yet to be broadcasted.
pub needs_broadcast: bool,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I like this idea if done as a non-exhaustive enum to help reduce future API breaking changes. If we include a "LOCKED" variant could this also support #259?

Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
let tx = match tx_graph.get_tx(txid) {
Some(tx) => tx,
None => {
debug_assert!(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Would it be better to throw and Err here instead of the panic? It seems possible a user could mistakenly try to queue a Txid not in the tx_graph. Or is this ment to warn app devs that they should never let this happen?

I also don't understand why you only panic if the txid is not in the tx_graphand not in the dedup set. Isn't not having the Txid in the graph enough to panic due to it being invalid?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Sorry this was never meant to be in the public API. The idea is that we should only add txids into the BroadcastQueue which are also in TxGraph. If that is not the case, it is definitely an internal BDK error.

Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
@evanlinjin

evanlinjin commented Jun 15, 2025

Copy link
Copy Markdown
MemberAuthor

Can we enforce or support BroadcastQueue as the only way to broadcast transactions in bdk_wallet? A user bypassing this mechanism should be considered? Are there reasons to not doing it? Why would you keep broadcasting tx outside of the queue when you have unbroadcasted tx in the queue?

@nymius I've rethought about this problem. I think instead of a BroadcastQueue, it should really be an IntentTracker (refer to my comment here). Broadcast-ability can be evaluated on a trasaction-by-transaction basis. I do not think it is viable to enforce BroadcastQueue as the only way to broadcast transactions are BDK is not responsible for broadcasting directly to the mempool.

I like that the queue purges itself automatically on syncs.

@thunderbiscuit I agree that it is nice to reason with. However, I think it will introduce some footguns. Let me provide an example:

  • Transaction A (an intended payment) is broadcasted.
  • Transaction A gets evicted from the mempool so it disappears from the transaction list.
  • The user realizes this and creates a second transaction (B) to atone for the disappearance of transaction A. However, the coin selection puts A and B on non-conflicting subgraphs.

Now A and B can exist in the same history, and thus we have the potential birth of a double-payment situation.

My proposal right now, as mentioned above, is to have an IntentTracker which the user needs to explicitly forget or replace a "diverged" transaction.

The wallet will keep track of two consistent views of history:

  1. The canonical network view. This is what BDK assumes to be what the network sees. Currently wallet.transactions returns this.
  2. The canonical intent view. This is what the user intends to happen.

If these two views are the same, no action is required. If these two views diverge, the caller should be able to easily respond to it explicitly.

  • The tx merely needs a broadcast.
  • The tx is low fee so needs RBF/CPFP.
  • An input is no longer available. RBF?
  • Explicitly forgetting (this is safe if a conflict is x number of confirmations deep).

@evanlinjin

evanlinjin commented Jun 17, 2025

Copy link
Copy Markdown
MemberAuthor

When doing coin selection, we should use the "intent view" to obtain the UTXO set. This is to avoid accidentally double-spending intended-to-be-canonical transactions.

However, some intended-to-be-canonical transactions could not be canonical now (due to confirmed conflicts), or conflicts with mempool transactions (RBF, which may not go through in time).

So there should be some sort of filtering based on transactions in the IntentTracker:

  • Don't spend from transactions with confirmed conflicts.
  • Try to avoid spending from transactions with unconfirmed conflicts.
  • Try to avoid spending from unbroadcasted transactions.
  • Try to avoid spending from evicted transactions.

Of course, there are other filters that BDK does not do, but should really do (out of scope of this PR, but probably part of the same interface/structure):

  • Try to avoid spending from untrusted unconfirmed outputs (as they can be cancelled/replaced by another party).
  • Try to avoid spending from unconfirmed transactions in general.

@tnull

tnull commented Jun 18, 2025

Copy link
Copy Markdown
Contributor

Do we know how Core handles these things?

@tnull do you mean how does the Core wallet handle un-broadcasted Tx and building new Tx on those un-broadcasted Tx outputs? As far as I know there are no features in the Core wallet for this beyond you the user holding on to your signed and un-broadcast Tx and manually building on those Tx outputs with the commands:

1. [createrawtransaction](https://bitcoincore.org/en/doc/29.0.0/rpc/rawtransactions/createrawtransaction/)
2. [signrawtransactionwithwallet ](https://bitcoincore.org/en/doc/29.0.0/rpc/wallet/signrawtransactionwithwallet/)
3. when you're ready to broadcast any of these Tx [sendrawtransaction](https://bitcoincore.org/en/doc/29.0.0/rpc/rawtransactions/sendrawtransaction/)

Mh, right, regarding the UTXO locking usecase, it does feature a rather simple interface through lockunspent / listlockunspent though. As said on #166, that (mod maybe an auto-unlock feature) would likely be all we'd really need on our end for now, I think.

@nymius

nymius commented Jun 19, 2025

Copy link
Copy Markdown
Contributor

Thanks for modeling this, from my perspective, it resembles to React DOM and virtual DOM, and its reconciliation model.

The user realizes this and creates a second transaction (B) to atone for the disappearance of transaction A. However, the coin selection puts A and B on non-conflicting subgraphs.

A quick check: when you say non-conflicting subgraphs, it is implied B is not spending any inputs from A, but is spending to the same outputs, right?

My proposal right now, as mentioned above, is to have an IntentTracker which the user needs to explicitly forget or replace a "diverged" transaction.

Do you have in mind some diff method between this IntentTracker and the canonical to find these divergences?
For the updates, all these actions you mentioned (re-broadcast, rbf, cpfp, forget) will be implemented as IntentTrackers methods?


The canonical network view. This is what BDK assumes to be what the network sees. Currently wallet.transactions returns this.

I'm confused here, Wallet.transactions docs say the following:

/// Iterate over relevant and canonical transactions in the wallet.
///
/// A transaction is relevant when it spends from or spends to at least one tracked output. A
/// transaction is canonical when it is confirmed in the best chain, or does not conflict
/// with any transaction confirmed in the best chain.

My guess is relevant transactions should be left out of the equation here.

@notmandatorynotmandatory modified the milestone: Wallet 3.0.0Jun 25, 2025
@evanlinjinevanlinjin changed the title Introduce BroadcastQueueIntroduce IntentTrackerJun 28, 2025
This was referenced Jul 4, 2025
evanlinjin added a commit to bitcoindevkit/bdk that referenced this pull request Jul 26, 2025
51ee99a docs(bitcoind_rpc): fixed typo in docs (Wei Chen)
73ab1eb chore(bitcoind_rpc): Make clippy happy (志宇)
7e894f4 feat(bitcoind_rpc)!: Use `getrawmempool` without verbose (志宇)
05464ec fix(bitcoind_rpc)!: Simplify emitter (志宇)
67dfb0b test(bitcoind_rpc): Detect new mempool txs (志宇)
Pull request description:
### Description
There is a bug in `bdk_bitcoind_rpc` where some new mempool transactions will not be emitted at all.
This problem exists because the avoid-re-emission logic depends on rounded-to-nearest-second timestamps.
The fix is to just emit all mempool transactions but wrap them in `Arc`s so that emission becomes cheap.
**Background:** I tried using `bdk_bitcoind_rpc` as the chain-source to write an example to showcase the [`IntentTracker`](bitcoindevkit/bdk_wallet#257). However, `bdk_bitcoind_rpc` failed to emit some mempool transactions.
### Notes to the reviewers
The test added in c22c68f fails without these fixes.
Some tests are removed as they are no longer relevant.
### Changelog notice
```md
Fixed:
- Some mempool transactions not being emitted at all. The fix is to replace the avoid-re-emission-logic with one which emits all mempool transactions.
```
### Checklists
#### All Submissions:
* [x] I've signed all my commits
* [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md)
* [x] I ran `cargo +nightly fmt` and `cargo clippy` before committing
#### Bugfixes:
* [x] This pull request breaks the existing API
* [x] I've added tests to reproduce the issue which are now passing
~* [ ] I'm linking the issue being fixed by this PR~
ACKs for top commit:
nymius:
cACK 51ee99a
LagginTimes:
Re-ACK 51ee99a
Tree-SHA512: 04e180e1d28c3f4c581a61ccac95e8e7e6927123d272ed07eae0ae51bf70799df44298b47ba0e49a309fd76366875e8d18d73478252931713137844857b8ed5a
@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch from e3ec37b to 2f9249bCompareAugust 3, 2025 13:05
kwsantiago pushed a commit to privkeyio/bdk that referenced this pull request Aug 5, 2025
51ee99a docs(bitcoind_rpc): fixed typo in docs (Wei Chen)
73ab1eb chore(bitcoind_rpc): Make clippy happy (志宇)
7e894f4 feat(bitcoind_rpc)!: Use `getrawmempool` without verbose (志宇)
05464ec fix(bitcoind_rpc)!: Simplify emitter (志宇)
67dfb0b test(bitcoind_rpc): Detect new mempool txs (志宇)
Pull request description:
### Description
There is a bug in `bdk_bitcoind_rpc` where some new mempool transactions will not be emitted at all.
This problem exists because the avoid-re-emission logic depends on rounded-to-nearest-second timestamps.
The fix is to just emit all mempool transactions but wrap them in `Arc`s so that emission becomes cheap.
**Background:** I tried using `bdk_bitcoind_rpc` as the chain-source to write an example to showcase the [`IntentTracker`](bitcoindevkit/bdk_wallet#257). However, `bdk_bitcoind_rpc` failed to emit some mempool transactions.
### Notes to the reviewers
The test added in c22c68f fails without these fixes.
Some tests are removed as they are no longer relevant.
### Changelog notice
```md
Fixed:
- Some mempool transactions not being emitted at all. The fix is to replace the avoid-re-emission-logic with one which emits all mempool transactions.
```
### Checklists
#### All Submissions:
* [x] I've signed all my commits
* [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md)
* [x] I ran `cargo +nightly fmt` and `cargo clippy` before committing
#### Bugfixes:
* [x] This pull request breaks the existing API
* [x] I've added tests to reproduce the issue which are now passing
~* [ ] I'm linking the issue being fixed by this PR~
ACKs for top commit:
nymius:
cACK 51ee99a
LagginTimes:
Re-ACK 51ee99a
Tree-SHA512: 04e180e1d28c3f4c581a61ccac95e8e7e6927123d272ed07eae0ae51bf70799df44298b47ba0e49a309fd76366875e8d18d73478252931713137844857b8ed5a
@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch from 2f9249b to 5816070CompareAugust 7, 2025 08:39
@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch from 5816070 to 5d70885CompareAugust 29, 2025 01:30
@ovitrif

ovitrif commented Sep 8, 2025

Copy link
Copy Markdown

Hi guys, Bitkit team dev here, needing this to unlock:

Why?

EDIT: nvm, #6 is now fixed by #310. Thank you for your attention and collaboration 🙏🏻.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new featureNew feature or request

Projects

Archived in project

8 participants

@evanlinjin@coveralls@thunderbiscuit@notmandatory@tnull@nymius@ovitrif@ValuedMammal
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Introduce `IntentTracker` by evanlinjin · Pull Request #257 · bitcoindevkit/bdk_wallet · GitHub
Skip to content

Introduce IntentTracker - #257

Closed
evanlinjin wants to merge 7 commits into
bitcoindevkit:masterfrom
evanlinjin:feature/broadcast-queue
Closed

Introduce IntentTracker#257
evanlinjin wants to merge 7 commits into
bitcoindevkit:masterfrom
evanlinjin:feature/broadcast-queue

Conversation

@evanlinjin

@evanlinjinevanlinjin commented Jun 6, 2025

Copy link
Copy Markdown
Member

Fixes#166
Fixes#40
Fixed#295
Replaces #220

Description

Allows callers to spend from unbroadcasted transactions.

Notes to the reviewers

I think I may have done some overthinking for the BroadcastQueue implementation. This is the current implementation:

  • Wallet::add_tx_to_broadcast_queue will also remove conflicts (of the tx being inserted) from the broadcast queue.
  • Wallet::remove_tx_from_broadcast_queue will also remove descendants of the tx being removed.

However, I’m not convinced this feature is necessary, and it could lead to inconsistent behavior if callers sometimes use the BroadcastQueue, bypass it to broadcast transactions directly, or if multiple instances of the same wallet broadcast concurrently. In such cases—when intermediate transactions are missing from the queue—the logic described above will fail.

There is an argument for RBF, however, why would you need to RBF unbroadcasted transactions? It's better to empty the queue and start again.

Changelog notice

Checklists

To Get Out of Draft Status:

  • Have a section in the struct-level (Wallet) docs that explains the broadcast queue.
  • Better docs for each new method added.
  • Example: Wallet with single UTXO. Create x number of transactions sequentially. Broadcast all in one go. Sync.
  • Test persistence (sqlite).
  • More tests.

To Get This Merged:

All Submissions:

  • I've signed all my commits
  • I followed the contribution guidelines
  • I ran cargo +nightly fmt and cargo clippy before committing

New Features:

  • I've added tests for the new feature
  • I've added docs for the new feature

Bugfixes:

  • This pull request breaks the existing API
  • I've added tests to reproduce the issue which are now passing
  • I'm linking the issue being fixed by this PR

@coveralls

coveralls commented Jun 6, 2025

Copy link
Copy Markdown

Pull Request Test Coverage Report for Build 15943002368

Warning: This coverage report may be inaccurate.

This pull request's base commit is no longer the HEAD commit of its target branch. This means it includes changes from outside the original pull request, including, potentially, unrelated coverage changes.

Details

  • 213 of 632(33.7%) changed or added relevant lines in 5 files are covered.
  • 14 unchanged lines in 5 files lost coverage.
  • Overall coverage decreased (-4.9%) to 80.602%

Changes Missing CoverageCovered LinesChanged/Added Lines%
wallet/src/wallet/tx_builder.rs21020.0%
wallet/src/wallet/changeset.rs254160.98%
wallet/src/wallet/mod.rs13522260.81%
wallet/src/wallet/intent_tracker.rs4935713.73%
Files with Coverage ReductionNew Missed Lines%
wallet/src/descriptor/dsl.rs195.34%
wallet/src/wallet/changeset.rs279.44%
wallet/src/descriptor/policy.rs379.07%
wallet/src/descriptor/template.rs498.04%
wallet/src/wallet/mod.rs478.06%
TotalsCoverage Status
Change from base Build 15476130196:-4.9%
Covered Lines:6644
Relevant Lines:8243

💛 - Coveralls

@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch 2 times, most recently from 808bbdf to 75bc892CompareJune 6, 2025 10:27
@evanlinjinevanlinjin self-assigned this Jun 7, 2025
@notmandatorynotmandatory moved this to In Progress in BDK WalletJun 7, 2025
@notmandatorynotmandatory added the new feature New feature or request label Jun 7, 2025
Comment threadwallet/src/types.rs
/// The position of the output in the blockchain.
pub chain_position: ChainPosition<ConfirmationBlockTime>,
/// Whether this output exists in a transaction that is yet to be broadcasted.
pub needs_broadcast: bool,

@nymiusnymiusJun 8, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe an enum state field with something like: UNSPENT, ON_QUEUE, SPENT, BROADCASTED will avoid keep adding new boolean fields here, and provide a better path for update on future occasions, taking advantage of non exhaustive patters. is_spent could be marked for deprecation and be used along the new field in the meantime.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I like this idea if done as a non-exhaustive enum to help reduce future API breaking changes. If we include a "LOCKED" variant could this also support #259?

@nymiusnymius left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

However, I’m not convinced this feature is necessary, and it could lead to inconsistent behavior if callers sometimes use the BroadcastQueue, bypass it to broadcast transactions directly, or if multiple instances of the same wallet broadcast concurrently. In such cases—when intermediate transactions are missing from the queue—the logic described above will fail.

Can we enforce or support BroadcastQueue as the only way to broadcast transactions in bdk_wallet?
A user bypassing this mechanism should be considered? Are there reasons to not doing it?
Why would you keep broadcasting tx outside of the queue when you have an unbroadcasted tx in the queue?

I like the approach, and think is easy to reason about. Maybe we could leave the door open to implement other broadcast policies.
IMHO, the BroadcastQueue "profile" should ensure internal consistency, so I don't think is over engineered.

Why would you need to RBF unbroadcasted transactions?

Not a use case that I've needed, but maybe share multiple conflicting transactions offline looking for fee optimization in different scenarios.

@nymiusnymius mentioned this pull request Jun 8, 2025
7 tasks
@thunderbiscuit

thunderbiscuit commented Jun 9, 2025

Copy link
Copy Markdown
Member

Concept ACK. I like the idea of the queue.

I took a look at the diff and here are some thoughts/questions, pardon me if some of them would have been answered by doing a code deep dive, I just know you wanted early feedback so decided to get moving on it sooner than later.

  • Simple is good in my mind. If one of the requirements of the queue is that it's always internally valid and could in theory be broadcast all at once in one go, that's an easier mental model than allowing conflicts in the queue.
  • If the queue can actually have conflicts, it's less of a queue and more of a "bag" of transactions. Again less easy to reason about, and now the naming is misleading from the point of view of the users (I mean it's not that bad, I just mean it's not as neat/pure)
  • I like that the queue purges itself automatically on syncs.
  • I am potentially drawn to the idea from @nymius that all transactions could need to go in the queue first to then be broadcast. I wonder if that's an elegant way to force clean setups and handle the fact that a ton of wallets probably don't do costly sync every time they build transactions. That way the queue would always be aware of what has been broadcast. Does that complicate things too much? It would just be important that the library not have any footguns that would for example have you forget about a tx in the queue, persist it, then weeks later you just do Wallet::broadcast_queue and bam you just sent more than you wanted.

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for tackling this, took a first look.

I think I may have done some overthinking for the BroadcastQueue implementation. This is the current implementation:

Do we know how Core handles these things?

Also, when do we expect this queue to be processed? Would this happen manually or automatically in intervals?

Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
queue: VecDeque<Txid>,

/// Enforces that we do not have duplicates in `queue`.
dedup: HashSet<Txid>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I wonder if it's worth having this separate set? How many unbroadcasted transactions are we expecting at any given time? Maybe it would just be quicker to simply iterate over the queue itself, also saving the heap allocations/memory footprint?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

That is a good point. Maybe premature optimization here.

Comment threadwallet/src/types.rs
/// The position of the output in the blockchain.
pub chain_position: ChainPosition<ConfirmationBlockTime>,
/// Whether this output exists in a transaction that is yet to be broadcasted.
pub needs_broadcast: bool,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When do we expect this to be set/unset exactly? I guess it can only be unset once the transaction in question reaches threshold confirmations?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

That is a good point, and it shows the limitations of the BroadcastQueue concept. In fact, I did some further thinking on this and the BroadcastQueue should really be an IntentTracker and should track txs even if they are "network canonical".

There should be a method such as .tracked_txs_which_are_not_network_canonical (better name needed) so that the caller can decide to either replace the tx, or explicitly forget about it. There are caveats to doing both since we don't want to create a sub-graph where intended payments are duplicated - BDK should handle these situations properly, or provide the required information so that the caller can make a safe decision.

@notmandatory

notmandatory commented Jun 12, 2025

Copy link
Copy Markdown
Member

Do we know how Core handles these things?

@tnull do you mean how does the Core wallet handle un-broadcasted Tx and building new Tx on those un-broadcasted Tx outputs? As far as I know there are no features in the Core wallet for this beyond you the user holding on to your signed and un-broadcast Tx and manually building on those Tx outputs with the commands:

  1. createrawtransaction
  2. signrawtransactionwithwallet
  3. when you're ready to broadcast any of these Tx sendrawtransaction

@notmandatorynotmandatory left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Overall looks like a powerful new feature, I only have minor comments. Once you feel the API is ready I'd like to have a live chat to review it with L2 users like @tnull and @stevenroose to validate it meets their use cases.

Comment threadwallet/src/types.rs
/// The position of the output in the blockchain.
pub chain_position: ChainPosition<ConfirmationBlockTime>,
/// Whether this output exists in a transaction that is yet to be broadcasted.
pub needs_broadcast: bool,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I like this idea if done as a non-exhaustive enum to help reduce future API breaking changes. If we include a "LOCKED" variant could this also support #259?

Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
let tx = match tx_graph.get_tx(txid) {
Some(tx) => tx,
None => {
debug_assert!(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Would it be better to throw and Err here instead of the panic? It seems possible a user could mistakenly try to queue a Txid not in the tx_graph. Or is this ment to warn app devs that they should never let this happen?

I also don't understand why you only panic if the txid is not in the tx_graphand not in the dedup set. Isn't not having the Txid in the graph enough to panic due to it being invalid?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Sorry this was never meant to be in the public API. The idea is that we should only add txids into the BroadcastQueue which are also in TxGraph. If that is not the case, it is definitely an internal BDK error.

Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
@evanlinjin

evanlinjin commented Jun 15, 2025

Copy link
Copy Markdown
MemberAuthor

Can we enforce or support BroadcastQueue as the only way to broadcast transactions in bdk_wallet? A user bypassing this mechanism should be considered? Are there reasons to not doing it? Why would you keep broadcasting tx outside of the queue when you have unbroadcasted tx in the queue?

@nymius I've rethought about this problem. I think instead of a BroadcastQueue, it should really be an IntentTracker (refer to my comment here). Broadcast-ability can be evaluated on a trasaction-by-transaction basis. I do not think it is viable to enforce BroadcastQueue as the only way to broadcast transactions are BDK is not responsible for broadcasting directly to the mempool.

I like that the queue purges itself automatically on syncs.

@thunderbiscuit I agree that it is nice to reason with. However, I think it will introduce some footguns. Let me provide an example:

  • Transaction A (an intended payment) is broadcasted.
  • Transaction A gets evicted from the mempool so it disappears from the transaction list.
  • The user realizes this and creates a second transaction (B) to atone for the disappearance of transaction A. However, the coin selection puts A and B on non-conflicting subgraphs.

Now A and B can exist in the same history, and thus we have the potential birth of a double-payment situation.

My proposal right now, as mentioned above, is to have an IntentTracker which the user needs to explicitly forget or replace a "diverged" transaction.

The wallet will keep track of two consistent views of history:

  1. The canonical network view. This is what BDK assumes to be what the network sees. Currently wallet.transactions returns this.
  2. The canonical intent view. This is what the user intends to happen.

If these two views are the same, no action is required. If these two views diverge, the caller should be able to easily respond to it explicitly.

  • The tx merely needs a broadcast.
  • The tx is low fee so needs RBF/CPFP.
  • An input is no longer available. RBF?
  • Explicitly forgetting (this is safe if a conflict is x number of confirmations deep).

@evanlinjin

evanlinjin commented Jun 17, 2025

Copy link
Copy Markdown
MemberAuthor

When doing coin selection, we should use the "intent view" to obtain the UTXO set. This is to avoid accidentally double-spending intended-to-be-canonical transactions.

However, some intended-to-be-canonical transactions could not be canonical now (due to confirmed conflicts), or conflicts with mempool transactions (RBF, which may not go through in time).

So there should be some sort of filtering based on transactions in the IntentTracker:

  • Don't spend from transactions with confirmed conflicts.
  • Try to avoid spending from transactions with unconfirmed conflicts.
  • Try to avoid spending from unbroadcasted transactions.
  • Try to avoid spending from evicted transactions.

Of course, there are other filters that BDK does not do, but should really do (out of scope of this PR, but probably part of the same interface/structure):

  • Try to avoid spending from untrusted unconfirmed outputs (as they can be cancelled/replaced by another party).
  • Try to avoid spending from unconfirmed transactions in general.

@tnull

tnull commented Jun 18, 2025

Copy link
Copy Markdown
Contributor

Do we know how Core handles these things?

@tnull do you mean how does the Core wallet handle un-broadcasted Tx and building new Tx on those un-broadcasted Tx outputs? As far as I know there are no features in the Core wallet for this beyond you the user holding on to your signed and un-broadcast Tx and manually building on those Tx outputs with the commands:

1. [createrawtransaction](https://bitcoincore.org/en/doc/29.0.0/rpc/rawtransactions/createrawtransaction/)
2. [signrawtransactionwithwallet ](https://bitcoincore.org/en/doc/29.0.0/rpc/wallet/signrawtransactionwithwallet/)
3. when you're ready to broadcast any of these Tx [sendrawtransaction](https://bitcoincore.org/en/doc/29.0.0/rpc/rawtransactions/sendrawtransaction/)

Mh, right, regarding the UTXO locking usecase, it does feature a rather simple interface through lockunspent / listlockunspent though. As said on #166, that (mod maybe an auto-unlock feature) would likely be all we'd really need on our end for now, I think.

@nymius

nymius commented Jun 19, 2025

Copy link
Copy Markdown
Contributor

Thanks for modeling this, from my perspective, it resembles to React DOM and virtual DOM, and its reconciliation model.

The user realizes this and creates a second transaction (B) to atone for the disappearance of transaction A. However, the coin selection puts A and B on non-conflicting subgraphs.

A quick check: when you say non-conflicting subgraphs, it is implied B is not spending any inputs from A, but is spending to the same outputs, right?

My proposal right now, as mentioned above, is to have an IntentTracker which the user needs to explicitly forget or replace a "diverged" transaction.

Do you have in mind some diff method between this IntentTracker and the canonical to find these divergences?
For the updates, all these actions you mentioned (re-broadcast, rbf, cpfp, forget) will be implemented as IntentTrackers methods?


The canonical network view. This is what BDK assumes to be what the network sees. Currently wallet.transactions returns this.

I'm confused here, Wallet.transactions docs say the following:

/// Iterate over relevant and canonical transactions in the wallet.
///
/// A transaction is relevant when it spends from or spends to at least one tracked output. A
/// transaction is canonical when it is confirmed in the best chain, or does not conflict
/// with any transaction confirmed in the best chain.

My guess is relevant transactions should be left out of the equation here.

@notmandatorynotmandatory modified the milestone: Wallet 3.0.0Jun 25, 2025
@evanlinjinevanlinjin changed the title Introduce BroadcastQueueIntroduce IntentTrackerJun 28, 2025
This was referenced Jul 4, 2025
evanlinjin added a commit to bitcoindevkit/bdk that referenced this pull request Jul 26, 2025
51ee99a docs(bitcoind_rpc): fixed typo in docs (Wei Chen)
73ab1eb chore(bitcoind_rpc): Make clippy happy (志宇)
7e894f4 feat(bitcoind_rpc)!: Use `getrawmempool` without verbose (志宇)
05464ec fix(bitcoind_rpc)!: Simplify emitter (志宇)
67dfb0b test(bitcoind_rpc): Detect new mempool txs (志宇)
Pull request description:
### Description
There is a bug in `bdk_bitcoind_rpc` where some new mempool transactions will not be emitted at all.
This problem exists because the avoid-re-emission logic depends on rounded-to-nearest-second timestamps.
The fix is to just emit all mempool transactions but wrap them in `Arc`s so that emission becomes cheap.
**Background:** I tried using `bdk_bitcoind_rpc` as the chain-source to write an example to showcase the [`IntentTracker`](bitcoindevkit/bdk_wallet#257). However, `bdk_bitcoind_rpc` failed to emit some mempool transactions.
### Notes to the reviewers
The test added in c22c68f fails without these fixes.
Some tests are removed as they are no longer relevant.
### Changelog notice
```md
Fixed:
- Some mempool transactions not being emitted at all. The fix is to replace the avoid-re-emission-logic with one which emits all mempool transactions.
```
### Checklists
#### All Submissions:
* [x] I've signed all my commits
* [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md)
* [x] I ran `cargo +nightly fmt` and `cargo clippy` before committing
#### Bugfixes:
* [x] This pull request breaks the existing API
* [x] I've added tests to reproduce the issue which are now passing
~* [ ] I'm linking the issue being fixed by this PR~
ACKs for top commit:
nymius:
cACK 51ee99a
LagginTimes:
Re-ACK 51ee99a
Tree-SHA512: 04e180e1d28c3f4c581a61ccac95e8e7e6927123d272ed07eae0ae51bf70799df44298b47ba0e49a309fd76366875e8d18d73478252931713137844857b8ed5a
@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch from e3ec37b to 2f9249bCompareAugust 3, 2025 13:05
kwsantiago pushed a commit to privkeyio/bdk that referenced this pull request Aug 5, 2025
51ee99a docs(bitcoind_rpc): fixed typo in docs (Wei Chen)
73ab1eb chore(bitcoind_rpc): Make clippy happy (志宇)
7e894f4 feat(bitcoind_rpc)!: Use `getrawmempool` without verbose (志宇)
05464ec fix(bitcoind_rpc)!: Simplify emitter (志宇)
67dfb0b test(bitcoind_rpc): Detect new mempool txs (志宇)
Pull request description:
### Description
There is a bug in `bdk_bitcoind_rpc` where some new mempool transactions will not be emitted at all.
This problem exists because the avoid-re-emission logic depends on rounded-to-nearest-second timestamps.
The fix is to just emit all mempool transactions but wrap them in `Arc`s so that emission becomes cheap.
**Background:** I tried using `bdk_bitcoind_rpc` as the chain-source to write an example to showcase the [`IntentTracker`](bitcoindevkit/bdk_wallet#257). However, `bdk_bitcoind_rpc` failed to emit some mempool transactions.
### Notes to the reviewers
The test added in c22c68f fails without these fixes.
Some tests are removed as they are no longer relevant.
### Changelog notice
```md
Fixed:
- Some mempool transactions not being emitted at all. The fix is to replace the avoid-re-emission-logic with one which emits all mempool transactions.
```
### Checklists
#### All Submissions:
* [x] I've signed all my commits
* [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md)
* [x] I ran `cargo +nightly fmt` and `cargo clippy` before committing
#### Bugfixes:
* [x] This pull request breaks the existing API
* [x] I've added tests to reproduce the issue which are now passing
~* [ ] I'm linking the issue being fixed by this PR~
ACKs for top commit:
nymius:
cACK 51ee99a
LagginTimes:
Re-ACK 51ee99a
Tree-SHA512: 04e180e1d28c3f4c581a61ccac95e8e7e6927123d272ed07eae0ae51bf70799df44298b47ba0e49a309fd76366875e8d18d73478252931713137844857b8ed5a
@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch from 2f9249b to 5816070CompareAugust 7, 2025 08:39
@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch from 5816070 to 5d70885CompareAugust 29, 2025 01:30
@ovitrif

ovitrif commented Sep 8, 2025

Copy link
Copy Markdown

Hi guys, Bitkit team dev here, needing this to unlock:

Why?

EDIT: nvm, #6 is now fixed by #310. Thank you for your attention and collaboration 🙏🏻.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new featureNew feature or request

Projects

Archived in project

8 participants

@evanlinjin@coveralls@thunderbiscuit@notmandatory@tnull@nymius@ovitrif@ValuedMammal
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Introduce `IntentTracker` by evanlinjin · Pull Request #257 · bitcoindevkit/bdk_wallet · GitHub
Skip to content

Introduce IntentTracker - #257

Closed
evanlinjin wants to merge 7 commits into
bitcoindevkit:masterfrom
evanlinjin:feature/broadcast-queue
Closed

Introduce IntentTracker#257
evanlinjin wants to merge 7 commits into
bitcoindevkit:masterfrom
evanlinjin:feature/broadcast-queue

Conversation

@evanlinjin

@evanlinjinevanlinjin commented Jun 6, 2025

Copy link
Copy Markdown
Member

Fixes#166
Fixes#40
Fixed#295
Replaces #220

Description

Allows callers to spend from unbroadcasted transactions.

Notes to the reviewers

I think I may have done some overthinking for the BroadcastQueue implementation. This is the current implementation:

  • Wallet::add_tx_to_broadcast_queue will also remove conflicts (of the tx being inserted) from the broadcast queue.
  • Wallet::remove_tx_from_broadcast_queue will also remove descendants of the tx being removed.

However, I’m not convinced this feature is necessary, and it could lead to inconsistent behavior if callers sometimes use the BroadcastQueue, bypass it to broadcast transactions directly, or if multiple instances of the same wallet broadcast concurrently. In such cases—when intermediate transactions are missing from the queue—the logic described above will fail.

There is an argument for RBF, however, why would you need to RBF unbroadcasted transactions? It's better to empty the queue and start again.

Changelog notice

Checklists

To Get Out of Draft Status:

  • Have a section in the struct-level (Wallet) docs that explains the broadcast queue.
  • Better docs for each new method added.
  • Example: Wallet with single UTXO. Create x number of transactions sequentially. Broadcast all in one go. Sync.
  • Test persistence (sqlite).
  • More tests.

To Get This Merged:

All Submissions:

  • I've signed all my commits
  • I followed the contribution guidelines
  • I ran cargo +nightly fmt and cargo clippy before committing

New Features:

  • I've added tests for the new feature
  • I've added docs for the new feature

Bugfixes:

  • This pull request breaks the existing API
  • I've added tests to reproduce the issue which are now passing
  • I'm linking the issue being fixed by this PR

@coveralls

coveralls commented Jun 6, 2025

Copy link
Copy Markdown

Pull Request Test Coverage Report for Build 15943002368

Warning: This coverage report may be inaccurate.

This pull request's base commit is no longer the HEAD commit of its target branch. This means it includes changes from outside the original pull request, including, potentially, unrelated coverage changes.

Details

  • 213 of 632(33.7%) changed or added relevant lines in 5 files are covered.
  • 14 unchanged lines in 5 files lost coverage.
  • Overall coverage decreased (-4.9%) to 80.602%

Changes Missing CoverageCovered LinesChanged/Added Lines%
wallet/src/wallet/tx_builder.rs21020.0%
wallet/src/wallet/changeset.rs254160.98%
wallet/src/wallet/mod.rs13522260.81%
wallet/src/wallet/intent_tracker.rs4935713.73%
Files with Coverage ReductionNew Missed Lines%
wallet/src/descriptor/dsl.rs195.34%
wallet/src/wallet/changeset.rs279.44%
wallet/src/descriptor/policy.rs379.07%
wallet/src/descriptor/template.rs498.04%
wallet/src/wallet/mod.rs478.06%
TotalsCoverage Status
Change from base Build 15476130196:-4.9%
Covered Lines:6644
Relevant Lines:8243

💛 - Coveralls

@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch 2 times, most recently from 808bbdf to 75bc892CompareJune 6, 2025 10:27
@evanlinjinevanlinjin self-assigned this Jun 7, 2025
@notmandatorynotmandatory moved this to In Progress in BDK WalletJun 7, 2025
@notmandatorynotmandatory added the new feature New feature or request label Jun 7, 2025
Comment threadwallet/src/types.rs
/// The position of the output in the blockchain.
pub chain_position: ChainPosition<ConfirmationBlockTime>,
/// Whether this output exists in a transaction that is yet to be broadcasted.
pub needs_broadcast: bool,

@nymiusnymiusJun 8, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe an enum state field with something like: UNSPENT, ON_QUEUE, SPENT, BROADCASTED will avoid keep adding new boolean fields here, and provide a better path for update on future occasions, taking advantage of non exhaustive patters. is_spent could be marked for deprecation and be used along the new field in the meantime.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I like this idea if done as a non-exhaustive enum to help reduce future API breaking changes. If we include a "LOCKED" variant could this also support #259?

@nymiusnymius left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

However, I’m not convinced this feature is necessary, and it could lead to inconsistent behavior if callers sometimes use the BroadcastQueue, bypass it to broadcast transactions directly, or if multiple instances of the same wallet broadcast concurrently. In such cases—when intermediate transactions are missing from the queue—the logic described above will fail.

Can we enforce or support BroadcastQueue as the only way to broadcast transactions in bdk_wallet?
A user bypassing this mechanism should be considered? Are there reasons to not doing it?
Why would you keep broadcasting tx outside of the queue when you have an unbroadcasted tx in the queue?

I like the approach, and think is easy to reason about. Maybe we could leave the door open to implement other broadcast policies.
IMHO, the BroadcastQueue "profile" should ensure internal consistency, so I don't think is over engineered.

Why would you need to RBF unbroadcasted transactions?

Not a use case that I've needed, but maybe share multiple conflicting transactions offline looking for fee optimization in different scenarios.

@nymiusnymius mentioned this pull request Jun 8, 2025
7 tasks
@thunderbiscuit

thunderbiscuit commented Jun 9, 2025

Copy link
Copy Markdown
Member

Concept ACK. I like the idea of the queue.

I took a look at the diff and here are some thoughts/questions, pardon me if some of them would have been answered by doing a code deep dive, I just know you wanted early feedback so decided to get moving on it sooner than later.

  • Simple is good in my mind. If one of the requirements of the queue is that it's always internally valid and could in theory be broadcast all at once in one go, that's an easier mental model than allowing conflicts in the queue.
  • If the queue can actually have conflicts, it's less of a queue and more of a "bag" of transactions. Again less easy to reason about, and now the naming is misleading from the point of view of the users (I mean it's not that bad, I just mean it's not as neat/pure)
  • I like that the queue purges itself automatically on syncs.
  • I am potentially drawn to the idea from @nymius that all transactions could need to go in the queue first to then be broadcast. I wonder if that's an elegant way to force clean setups and handle the fact that a ton of wallets probably don't do costly sync every time they build transactions. That way the queue would always be aware of what has been broadcast. Does that complicate things too much? It would just be important that the library not have any footguns that would for example have you forget about a tx in the queue, persist it, then weeks later you just do Wallet::broadcast_queue and bam you just sent more than you wanted.

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for tackling this, took a first look.

I think I may have done some overthinking for the BroadcastQueue implementation. This is the current implementation:

Do we know how Core handles these things?

Also, when do we expect this queue to be processed? Would this happen manually or automatically in intervals?

Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
queue: VecDeque<Txid>,

/// Enforces that we do not have duplicates in `queue`.
dedup: HashSet<Txid>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I wonder if it's worth having this separate set? How many unbroadcasted transactions are we expecting at any given time? Maybe it would just be quicker to simply iterate over the queue itself, also saving the heap allocations/memory footprint?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

That is a good point. Maybe premature optimization here.

Comment threadwallet/src/types.rs
/// The position of the output in the blockchain.
pub chain_position: ChainPosition<ConfirmationBlockTime>,
/// Whether this output exists in a transaction that is yet to be broadcasted.
pub needs_broadcast: bool,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When do we expect this to be set/unset exactly? I guess it can only be unset once the transaction in question reaches threshold confirmations?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

That is a good point, and it shows the limitations of the BroadcastQueue concept. In fact, I did some further thinking on this and the BroadcastQueue should really be an IntentTracker and should track txs even if they are "network canonical".

There should be a method such as .tracked_txs_which_are_not_network_canonical (better name needed) so that the caller can decide to either replace the tx, or explicitly forget about it. There are caveats to doing both since we don't want to create a sub-graph where intended payments are duplicated - BDK should handle these situations properly, or provide the required information so that the caller can make a safe decision.

@notmandatory

notmandatory commented Jun 12, 2025

Copy link
Copy Markdown
Member

Do we know how Core handles these things?

@tnull do you mean how does the Core wallet handle un-broadcasted Tx and building new Tx on those un-broadcasted Tx outputs? As far as I know there are no features in the Core wallet for this beyond you the user holding on to your signed and un-broadcast Tx and manually building on those Tx outputs with the commands:

  1. createrawtransaction
  2. signrawtransactionwithwallet
  3. when you're ready to broadcast any of these Tx sendrawtransaction

@notmandatorynotmandatory left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Overall looks like a powerful new feature, I only have minor comments. Once you feel the API is ready I'd like to have a live chat to review it with L2 users like @tnull and @stevenroose to validate it meets their use cases.

Comment threadwallet/src/types.rs
/// The position of the output in the blockchain.
pub chain_position: ChainPosition<ConfirmationBlockTime>,
/// Whether this output exists in a transaction that is yet to be broadcasted.
pub needs_broadcast: bool,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I like this idea if done as a non-exhaustive enum to help reduce future API breaking changes. If we include a "LOCKED" variant could this also support #259?

Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
let tx = match tx_graph.get_tx(txid) {
Some(tx) => tx,
None => {
debug_assert!(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Would it be better to throw and Err here instead of the panic? It seems possible a user could mistakenly try to queue a Txid not in the tx_graph. Or is this ment to warn app devs that they should never let this happen?

I also don't understand why you only panic if the txid is not in the tx_graphand not in the dedup set. Isn't not having the Txid in the graph enough to panic due to it being invalid?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Sorry this was never meant to be in the public API. The idea is that we should only add txids into the BroadcastQueue which are also in TxGraph. If that is not the case, it is definitely an internal BDK error.

Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
@evanlinjin

evanlinjin commented Jun 15, 2025

Copy link
Copy Markdown
MemberAuthor

Can we enforce or support BroadcastQueue as the only way to broadcast transactions in bdk_wallet? A user bypassing this mechanism should be considered? Are there reasons to not doing it? Why would you keep broadcasting tx outside of the queue when you have unbroadcasted tx in the queue?

@nymius I've rethought about this problem. I think instead of a BroadcastQueue, it should really be an IntentTracker (refer to my comment here). Broadcast-ability can be evaluated on a trasaction-by-transaction basis. I do not think it is viable to enforce BroadcastQueue as the only way to broadcast transactions are BDK is not responsible for broadcasting directly to the mempool.

I like that the queue purges itself automatically on syncs.

@thunderbiscuit I agree that it is nice to reason with. However, I think it will introduce some footguns. Let me provide an example:

  • Transaction A (an intended payment) is broadcasted.
  • Transaction A gets evicted from the mempool so it disappears from the transaction list.
  • The user realizes this and creates a second transaction (B) to atone for the disappearance of transaction A. However, the coin selection puts A and B on non-conflicting subgraphs.

Now A and B can exist in the same history, and thus we have the potential birth of a double-payment situation.

My proposal right now, as mentioned above, is to have an IntentTracker which the user needs to explicitly forget or replace a "diverged" transaction.

The wallet will keep track of two consistent views of history:

  1. The canonical network view. This is what BDK assumes to be what the network sees. Currently wallet.transactions returns this.
  2. The canonical intent view. This is what the user intends to happen.

If these two views are the same, no action is required. If these two views diverge, the caller should be able to easily respond to it explicitly.

  • The tx merely needs a broadcast.
  • The tx is low fee so needs RBF/CPFP.
  • An input is no longer available. RBF?
  • Explicitly forgetting (this is safe if a conflict is x number of confirmations deep).

@evanlinjin

evanlinjin commented Jun 17, 2025

Copy link
Copy Markdown
MemberAuthor

When doing coin selection, we should use the "intent view" to obtain the UTXO set. This is to avoid accidentally double-spending intended-to-be-canonical transactions.

However, some intended-to-be-canonical transactions could not be canonical now (due to confirmed conflicts), or conflicts with mempool transactions (RBF, which may not go through in time).

So there should be some sort of filtering based on transactions in the IntentTracker:

  • Don't spend from transactions with confirmed conflicts.
  • Try to avoid spending from transactions with unconfirmed conflicts.
  • Try to avoid spending from unbroadcasted transactions.
  • Try to avoid spending from evicted transactions.

Of course, there are other filters that BDK does not do, but should really do (out of scope of this PR, but probably part of the same interface/structure):

  • Try to avoid spending from untrusted unconfirmed outputs (as they can be cancelled/replaced by another party).
  • Try to avoid spending from unconfirmed transactions in general.

@tnull

tnull commented Jun 18, 2025

Copy link
Copy Markdown
Contributor

Do we know how Core handles these things?

@tnull do you mean how does the Core wallet handle un-broadcasted Tx and building new Tx on those un-broadcasted Tx outputs? As far as I know there are no features in the Core wallet for this beyond you the user holding on to your signed and un-broadcast Tx and manually building on those Tx outputs with the commands:

1. [createrawtransaction](https://bitcoincore.org/en/doc/29.0.0/rpc/rawtransactions/createrawtransaction/)
2. [signrawtransactionwithwallet ](https://bitcoincore.org/en/doc/29.0.0/rpc/wallet/signrawtransactionwithwallet/)
3. when you're ready to broadcast any of these Tx [sendrawtransaction](https://bitcoincore.org/en/doc/29.0.0/rpc/rawtransactions/sendrawtransaction/)

Mh, right, regarding the UTXO locking usecase, it does feature a rather simple interface through lockunspent / listlockunspent though. As said on #166, that (mod maybe an auto-unlock feature) would likely be all we'd really need on our end for now, I think.

@nymius

nymius commented Jun 19, 2025

Copy link
Copy Markdown
Contributor

Thanks for modeling this, from my perspective, it resembles to React DOM and virtual DOM, and its reconciliation model.

The user realizes this and creates a second transaction (B) to atone for the disappearance of transaction A. However, the coin selection puts A and B on non-conflicting subgraphs.

A quick check: when you say non-conflicting subgraphs, it is implied B is not spending any inputs from A, but is spending to the same outputs, right?

My proposal right now, as mentioned above, is to have an IntentTracker which the user needs to explicitly forget or replace a "diverged" transaction.

Do you have in mind some diff method between this IntentTracker and the canonical to find these divergences?
For the updates, all these actions you mentioned (re-broadcast, rbf, cpfp, forget) will be implemented as IntentTrackers methods?


The canonical network view. This is what BDK assumes to be what the network sees. Currently wallet.transactions returns this.

I'm confused here, Wallet.transactions docs say the following:

/// Iterate over relevant and canonical transactions in the wallet.
///
/// A transaction is relevant when it spends from or spends to at least one tracked output. A
/// transaction is canonical when it is confirmed in the best chain, or does not conflict
/// with any transaction confirmed in the best chain.

My guess is relevant transactions should be left out of the equation here.

@notmandatorynotmandatory modified the milestone: Wallet 3.0.0Jun 25, 2025
@evanlinjinevanlinjin changed the title Introduce BroadcastQueueIntroduce IntentTrackerJun 28, 2025
This was referenced Jul 4, 2025
evanlinjin added a commit to bitcoindevkit/bdk that referenced this pull request Jul 26, 2025
51ee99a docs(bitcoind_rpc): fixed typo in docs (Wei Chen)
73ab1eb chore(bitcoind_rpc): Make clippy happy (志宇)
7e894f4 feat(bitcoind_rpc)!: Use `getrawmempool` without verbose (志宇)
05464ec fix(bitcoind_rpc)!: Simplify emitter (志宇)
67dfb0b test(bitcoind_rpc): Detect new mempool txs (志宇)
Pull request description:
### Description
There is a bug in `bdk_bitcoind_rpc` where some new mempool transactions will not be emitted at all.
This problem exists because the avoid-re-emission logic depends on rounded-to-nearest-second timestamps.
The fix is to just emit all mempool transactions but wrap them in `Arc`s so that emission becomes cheap.
**Background:** I tried using `bdk_bitcoind_rpc` as the chain-source to write an example to showcase the [`IntentTracker`](bitcoindevkit/bdk_wallet#257). However, `bdk_bitcoind_rpc` failed to emit some mempool transactions.
### Notes to the reviewers
The test added in c22c68f fails without these fixes.
Some tests are removed as they are no longer relevant.
### Changelog notice
```md
Fixed:
- Some mempool transactions not being emitted at all. The fix is to replace the avoid-re-emission-logic with one which emits all mempool transactions.
```
### Checklists
#### All Submissions:
* [x] I've signed all my commits
* [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md)
* [x] I ran `cargo +nightly fmt` and `cargo clippy` before committing
#### Bugfixes:
* [x] This pull request breaks the existing API
* [x] I've added tests to reproduce the issue which are now passing
~* [ ] I'm linking the issue being fixed by this PR~
ACKs for top commit:
nymius:
cACK 51ee99a
LagginTimes:
Re-ACK 51ee99a
Tree-SHA512: 04e180e1d28c3f4c581a61ccac95e8e7e6927123d272ed07eae0ae51bf70799df44298b47ba0e49a309fd76366875e8d18d73478252931713137844857b8ed5a
@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch from e3ec37b to 2f9249bCompareAugust 3, 2025 13:05
kwsantiago pushed a commit to privkeyio/bdk that referenced this pull request Aug 5, 2025
51ee99a docs(bitcoind_rpc): fixed typo in docs (Wei Chen)
73ab1eb chore(bitcoind_rpc): Make clippy happy (志宇)
7e894f4 feat(bitcoind_rpc)!: Use `getrawmempool` without verbose (志宇)
05464ec fix(bitcoind_rpc)!: Simplify emitter (志宇)
67dfb0b test(bitcoind_rpc): Detect new mempool txs (志宇)
Pull request description:
### Description
There is a bug in `bdk_bitcoind_rpc` where some new mempool transactions will not be emitted at all.
This problem exists because the avoid-re-emission logic depends on rounded-to-nearest-second timestamps.
The fix is to just emit all mempool transactions but wrap them in `Arc`s so that emission becomes cheap.
**Background:** I tried using `bdk_bitcoind_rpc` as the chain-source to write an example to showcase the [`IntentTracker`](bitcoindevkit/bdk_wallet#257). However, `bdk_bitcoind_rpc` failed to emit some mempool transactions.
### Notes to the reviewers
The test added in c22c68f fails without these fixes.
Some tests are removed as they are no longer relevant.
### Changelog notice
```md
Fixed:
- Some mempool transactions not being emitted at all. The fix is to replace the avoid-re-emission-logic with one which emits all mempool transactions.
```
### Checklists
#### All Submissions:
* [x] I've signed all my commits
* [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md)
* [x] I ran `cargo +nightly fmt` and `cargo clippy` before committing
#### Bugfixes:
* [x] This pull request breaks the existing API
* [x] I've added tests to reproduce the issue which are now passing
~* [ ] I'm linking the issue being fixed by this PR~
ACKs for top commit:
nymius:
cACK 51ee99a
LagginTimes:
Re-ACK 51ee99a
Tree-SHA512: 04e180e1d28c3f4c581a61ccac95e8e7e6927123d272ed07eae0ae51bf70799df44298b47ba0e49a309fd76366875e8d18d73478252931713137844857b8ed5a
@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch from 2f9249b to 5816070CompareAugust 7, 2025 08:39
@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch from 5816070 to 5d70885CompareAugust 29, 2025 01:30
@ovitrif

ovitrif commented Sep 8, 2025

Copy link
Copy Markdown

Hi guys, Bitkit team dev here, needing this to unlock:

Why?

EDIT: nvm, #6 is now fixed by #310. Thank you for your attention and collaboration 🙏🏻.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new featureNew feature or request

Projects

Archived in project

8 participants

@evanlinjin@coveralls@thunderbiscuit@notmandatory@tnull@nymius@ovitrif@ValuedMammal
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Introduce `IntentTracker` by evanlinjin · Pull Request #257 · bitcoindevkit/bdk_wallet · GitHub
Skip to content

Introduce IntentTracker - #257

Closed
evanlinjin wants to merge 7 commits into
bitcoindevkit:masterfrom
evanlinjin:feature/broadcast-queue
Closed

Introduce IntentTracker#257
evanlinjin wants to merge 7 commits into
bitcoindevkit:masterfrom
evanlinjin:feature/broadcast-queue

Conversation

@evanlinjin

@evanlinjinevanlinjin commented Jun 6, 2025

Copy link
Copy Markdown
Member

Fixes#166
Fixes#40
Fixed#295
Replaces #220

Description

Allows callers to spend from unbroadcasted transactions.

Notes to the reviewers

I think I may have done some overthinking for the BroadcastQueue implementation. This is the current implementation:

  • Wallet::add_tx_to_broadcast_queue will also remove conflicts (of the tx being inserted) from the broadcast queue.
  • Wallet::remove_tx_from_broadcast_queue will also remove descendants of the tx being removed.

However, I’m not convinced this feature is necessary, and it could lead to inconsistent behavior if callers sometimes use the BroadcastQueue, bypass it to broadcast transactions directly, or if multiple instances of the same wallet broadcast concurrently. In such cases—when intermediate transactions are missing from the queue—the logic described above will fail.

There is an argument for RBF, however, why would you need to RBF unbroadcasted transactions? It's better to empty the queue and start again.

Changelog notice

Checklists

To Get Out of Draft Status:

  • Have a section in the struct-level (Wallet) docs that explains the broadcast queue.
  • Better docs for each new method added.
  • Example: Wallet with single UTXO. Create x number of transactions sequentially. Broadcast all in one go. Sync.
  • Test persistence (sqlite).
  • More tests.

To Get This Merged:

All Submissions:

  • I've signed all my commits
  • I followed the contribution guidelines
  • I ran cargo +nightly fmt and cargo clippy before committing

New Features:

  • I've added tests for the new feature
  • I've added docs for the new feature

Bugfixes:

  • This pull request breaks the existing API
  • I've added tests to reproduce the issue which are now passing
  • I'm linking the issue being fixed by this PR

@coveralls

coveralls commented Jun 6, 2025

Copy link
Copy Markdown

Pull Request Test Coverage Report for Build 15943002368

Warning: This coverage report may be inaccurate.

This pull request's base commit is no longer the HEAD commit of its target branch. This means it includes changes from outside the original pull request, including, potentially, unrelated coverage changes.

Details

  • 213 of 632(33.7%) changed or added relevant lines in 5 files are covered.
  • 14 unchanged lines in 5 files lost coverage.
  • Overall coverage decreased (-4.9%) to 80.602%

Changes Missing CoverageCovered LinesChanged/Added Lines%
wallet/src/wallet/tx_builder.rs21020.0%
wallet/src/wallet/changeset.rs254160.98%
wallet/src/wallet/mod.rs13522260.81%
wallet/src/wallet/intent_tracker.rs4935713.73%
Files with Coverage ReductionNew Missed Lines%
wallet/src/descriptor/dsl.rs195.34%
wallet/src/wallet/changeset.rs279.44%
wallet/src/descriptor/policy.rs379.07%
wallet/src/descriptor/template.rs498.04%
wallet/src/wallet/mod.rs478.06%
TotalsCoverage Status
Change from base Build 15476130196:-4.9%
Covered Lines:6644
Relevant Lines:8243

💛 - Coveralls

@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch 2 times, most recently from 808bbdf to 75bc892CompareJune 6, 2025 10:27
@evanlinjinevanlinjin self-assigned this Jun 7, 2025
@notmandatorynotmandatory moved this to In Progress in BDK WalletJun 7, 2025
@notmandatorynotmandatory added the new feature New feature or request label Jun 7, 2025
Comment threadwallet/src/types.rs
/// The position of the output in the blockchain.
pub chain_position: ChainPosition<ConfirmationBlockTime>,
/// Whether this output exists in a transaction that is yet to be broadcasted.
pub needs_broadcast: bool,

@nymiusnymiusJun 8, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe an enum state field with something like: UNSPENT, ON_QUEUE, SPENT, BROADCASTED will avoid keep adding new boolean fields here, and provide a better path for update on future occasions, taking advantage of non exhaustive patters. is_spent could be marked for deprecation and be used along the new field in the meantime.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I like this idea if done as a non-exhaustive enum to help reduce future API breaking changes. If we include a "LOCKED" variant could this also support #259?

@nymiusnymius left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

However, I’m not convinced this feature is necessary, and it could lead to inconsistent behavior if callers sometimes use the BroadcastQueue, bypass it to broadcast transactions directly, or if multiple instances of the same wallet broadcast concurrently. In such cases—when intermediate transactions are missing from the queue—the logic described above will fail.

Can we enforce or support BroadcastQueue as the only way to broadcast transactions in bdk_wallet?
A user bypassing this mechanism should be considered? Are there reasons to not doing it?
Why would you keep broadcasting tx outside of the queue when you have an unbroadcasted tx in the queue?

I like the approach, and think is easy to reason about. Maybe we could leave the door open to implement other broadcast policies.
IMHO, the BroadcastQueue "profile" should ensure internal consistency, so I don't think is over engineered.

Why would you need to RBF unbroadcasted transactions?

Not a use case that I've needed, but maybe share multiple conflicting transactions offline looking for fee optimization in different scenarios.

@nymiusnymius mentioned this pull request Jun 8, 2025
7 tasks
@thunderbiscuit

thunderbiscuit commented Jun 9, 2025

Copy link
Copy Markdown
Member

Concept ACK. I like the idea of the queue.

I took a look at the diff and here are some thoughts/questions, pardon me if some of them would have been answered by doing a code deep dive, I just know you wanted early feedback so decided to get moving on it sooner than later.

  • Simple is good in my mind. If one of the requirements of the queue is that it's always internally valid and could in theory be broadcast all at once in one go, that's an easier mental model than allowing conflicts in the queue.
  • If the queue can actually have conflicts, it's less of a queue and more of a "bag" of transactions. Again less easy to reason about, and now the naming is misleading from the point of view of the users (I mean it's not that bad, I just mean it's not as neat/pure)
  • I like that the queue purges itself automatically on syncs.
  • I am potentially drawn to the idea from @nymius that all transactions could need to go in the queue first to then be broadcast. I wonder if that's an elegant way to force clean setups and handle the fact that a ton of wallets probably don't do costly sync every time they build transactions. That way the queue would always be aware of what has been broadcast. Does that complicate things too much? It would just be important that the library not have any footguns that would for example have you forget about a tx in the queue, persist it, then weeks later you just do Wallet::broadcast_queue and bam you just sent more than you wanted.

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for tackling this, took a first look.

I think I may have done some overthinking for the BroadcastQueue implementation. This is the current implementation:

Do we know how Core handles these things?

Also, when do we expect this queue to be processed? Would this happen manually or automatically in intervals?

Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
queue: VecDeque<Txid>,

/// Enforces that we do not have duplicates in `queue`.
dedup: HashSet<Txid>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I wonder if it's worth having this separate set? How many unbroadcasted transactions are we expecting at any given time? Maybe it would just be quicker to simply iterate over the queue itself, also saving the heap allocations/memory footprint?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

That is a good point. Maybe premature optimization here.

Comment threadwallet/src/types.rs
/// The position of the output in the blockchain.
pub chain_position: ChainPosition<ConfirmationBlockTime>,
/// Whether this output exists in a transaction that is yet to be broadcasted.
pub needs_broadcast: bool,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When do we expect this to be set/unset exactly? I guess it can only be unset once the transaction in question reaches threshold confirmations?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

That is a good point, and it shows the limitations of the BroadcastQueue concept. In fact, I did some further thinking on this and the BroadcastQueue should really be an IntentTracker and should track txs even if they are "network canonical".

There should be a method such as .tracked_txs_which_are_not_network_canonical (better name needed) so that the caller can decide to either replace the tx, or explicitly forget about it. There are caveats to doing both since we don't want to create a sub-graph where intended payments are duplicated - BDK should handle these situations properly, or provide the required information so that the caller can make a safe decision.

@notmandatory

notmandatory commented Jun 12, 2025

Copy link
Copy Markdown
Member

Do we know how Core handles these things?

@tnull do you mean how does the Core wallet handle un-broadcasted Tx and building new Tx on those un-broadcasted Tx outputs? As far as I know there are no features in the Core wallet for this beyond you the user holding on to your signed and un-broadcast Tx and manually building on those Tx outputs with the commands:

  1. createrawtransaction
  2. signrawtransactionwithwallet
  3. when you're ready to broadcast any of these Tx sendrawtransaction

@notmandatorynotmandatory left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Overall looks like a powerful new feature, I only have minor comments. Once you feel the API is ready I'd like to have a live chat to review it with L2 users like @tnull and @stevenroose to validate it meets their use cases.

Comment threadwallet/src/types.rs
/// The position of the output in the blockchain.
pub chain_position: ChainPosition<ConfirmationBlockTime>,
/// Whether this output exists in a transaction that is yet to be broadcasted.
pub needs_broadcast: bool,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I like this idea if done as a non-exhaustive enum to help reduce future API breaking changes. If we include a "LOCKED" variant could this also support #259?

Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
let tx = match tx_graph.get_tx(txid) {
Some(tx) => tx,
None => {
debug_assert!(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Would it be better to throw and Err here instead of the panic? It seems possible a user could mistakenly try to queue a Txid not in the tx_graph. Or is this ment to warn app devs that they should never let this happen?

I also don't understand why you only panic if the txid is not in the tx_graphand not in the dedup set. Isn't not having the Txid in the graph enough to panic due to it being invalid?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Sorry this was never meant to be in the public API. The idea is that we should only add txids into the BroadcastQueue which are also in TxGraph. If that is not the case, it is definitely an internal BDK error.

Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
@evanlinjin

evanlinjin commented Jun 15, 2025

Copy link
Copy Markdown
MemberAuthor

Can we enforce or support BroadcastQueue as the only way to broadcast transactions in bdk_wallet? A user bypassing this mechanism should be considered? Are there reasons to not doing it? Why would you keep broadcasting tx outside of the queue when you have unbroadcasted tx in the queue?

@nymius I've rethought about this problem. I think instead of a BroadcastQueue, it should really be an IntentTracker (refer to my comment here). Broadcast-ability can be evaluated on a trasaction-by-transaction basis. I do not think it is viable to enforce BroadcastQueue as the only way to broadcast transactions are BDK is not responsible for broadcasting directly to the mempool.

I like that the queue purges itself automatically on syncs.

@thunderbiscuit I agree that it is nice to reason with. However, I think it will introduce some footguns. Let me provide an example:

  • Transaction A (an intended payment) is broadcasted.
  • Transaction A gets evicted from the mempool so it disappears from the transaction list.
  • The user realizes this and creates a second transaction (B) to atone for the disappearance of transaction A. However, the coin selection puts A and B on non-conflicting subgraphs.

Now A and B can exist in the same history, and thus we have the potential birth of a double-payment situation.

My proposal right now, as mentioned above, is to have an IntentTracker which the user needs to explicitly forget or replace a "diverged" transaction.

The wallet will keep track of two consistent views of history:

  1. The canonical network view. This is what BDK assumes to be what the network sees. Currently wallet.transactions returns this.
  2. The canonical intent view. This is what the user intends to happen.

If these two views are the same, no action is required. If these two views diverge, the caller should be able to easily respond to it explicitly.

  • The tx merely needs a broadcast.
  • The tx is low fee so needs RBF/CPFP.
  • An input is no longer available. RBF?
  • Explicitly forgetting (this is safe if a conflict is x number of confirmations deep).

@evanlinjin

evanlinjin commented Jun 17, 2025

Copy link
Copy Markdown
MemberAuthor

When doing coin selection, we should use the "intent view" to obtain the UTXO set. This is to avoid accidentally double-spending intended-to-be-canonical transactions.

However, some intended-to-be-canonical transactions could not be canonical now (due to confirmed conflicts), or conflicts with mempool transactions (RBF, which may not go through in time).

So there should be some sort of filtering based on transactions in the IntentTracker:

  • Don't spend from transactions with confirmed conflicts.
  • Try to avoid spending from transactions with unconfirmed conflicts.
  • Try to avoid spending from unbroadcasted transactions.
  • Try to avoid spending from evicted transactions.

Of course, there are other filters that BDK does not do, but should really do (out of scope of this PR, but probably part of the same interface/structure):

  • Try to avoid spending from untrusted unconfirmed outputs (as they can be cancelled/replaced by another party).
  • Try to avoid spending from unconfirmed transactions in general.

@tnull

tnull commented Jun 18, 2025

Copy link
Copy Markdown
Contributor

Do we know how Core handles these things?

@tnull do you mean how does the Core wallet handle un-broadcasted Tx and building new Tx on those un-broadcasted Tx outputs? As far as I know there are no features in the Core wallet for this beyond you the user holding on to your signed and un-broadcast Tx and manually building on those Tx outputs with the commands:

1. [createrawtransaction](https://bitcoincore.org/en/doc/29.0.0/rpc/rawtransactions/createrawtransaction/)
2. [signrawtransactionwithwallet ](https://bitcoincore.org/en/doc/29.0.0/rpc/wallet/signrawtransactionwithwallet/)
3. when you're ready to broadcast any of these Tx [sendrawtransaction](https://bitcoincore.org/en/doc/29.0.0/rpc/rawtransactions/sendrawtransaction/)

Mh, right, regarding the UTXO locking usecase, it does feature a rather simple interface through lockunspent / listlockunspent though. As said on #166, that (mod maybe an auto-unlock feature) would likely be all we'd really need on our end for now, I think.

@nymius

nymius commented Jun 19, 2025

Copy link
Copy Markdown
Contributor

Thanks for modeling this, from my perspective, it resembles to React DOM and virtual DOM, and its reconciliation model.

The user realizes this and creates a second transaction (B) to atone for the disappearance of transaction A. However, the coin selection puts A and B on non-conflicting subgraphs.

A quick check: when you say non-conflicting subgraphs, it is implied B is not spending any inputs from A, but is spending to the same outputs, right?

My proposal right now, as mentioned above, is to have an IntentTracker which the user needs to explicitly forget or replace a "diverged" transaction.

Do you have in mind some diff method between this IntentTracker and the canonical to find these divergences?
For the updates, all these actions you mentioned (re-broadcast, rbf, cpfp, forget) will be implemented as IntentTrackers methods?


The canonical network view. This is what BDK assumes to be what the network sees. Currently wallet.transactions returns this.

I'm confused here, Wallet.transactions docs say the following:

/// Iterate over relevant and canonical transactions in the wallet.
///
/// A transaction is relevant when it spends from or spends to at least one tracked output. A
/// transaction is canonical when it is confirmed in the best chain, or does not conflict
/// with any transaction confirmed in the best chain.

My guess is relevant transactions should be left out of the equation here.

@notmandatorynotmandatory modified the milestone: Wallet 3.0.0Jun 25, 2025
@evanlinjinevanlinjin changed the title Introduce BroadcastQueueIntroduce IntentTrackerJun 28, 2025
This was referenced Jul 4, 2025
evanlinjin added a commit to bitcoindevkit/bdk that referenced this pull request Jul 26, 2025
51ee99a docs(bitcoind_rpc): fixed typo in docs (Wei Chen)
73ab1eb chore(bitcoind_rpc): Make clippy happy (志宇)
7e894f4 feat(bitcoind_rpc)!: Use `getrawmempool` without verbose (志宇)
05464ec fix(bitcoind_rpc)!: Simplify emitter (志宇)
67dfb0b test(bitcoind_rpc): Detect new mempool txs (志宇)
Pull request description:
### Description
There is a bug in `bdk_bitcoind_rpc` where some new mempool transactions will not be emitted at all.
This problem exists because the avoid-re-emission logic depends on rounded-to-nearest-second timestamps.
The fix is to just emit all mempool transactions but wrap them in `Arc`s so that emission becomes cheap.
**Background:** I tried using `bdk_bitcoind_rpc` as the chain-source to write an example to showcase the [`IntentTracker`](bitcoindevkit/bdk_wallet#257). However, `bdk_bitcoind_rpc` failed to emit some mempool transactions.
### Notes to the reviewers
The test added in c22c68f fails without these fixes.
Some tests are removed as they are no longer relevant.
### Changelog notice
```md
Fixed:
- Some mempool transactions not being emitted at all. The fix is to replace the avoid-re-emission-logic with one which emits all mempool transactions.
```
### Checklists
#### All Submissions:
* [x] I've signed all my commits
* [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md)
* [x] I ran `cargo +nightly fmt` and `cargo clippy` before committing
#### Bugfixes:
* [x] This pull request breaks the existing API
* [x] I've added tests to reproduce the issue which are now passing
~* [ ] I'm linking the issue being fixed by this PR~
ACKs for top commit:
nymius:
cACK 51ee99a
LagginTimes:
Re-ACK 51ee99a
Tree-SHA512: 04e180e1d28c3f4c581a61ccac95e8e7e6927123d272ed07eae0ae51bf70799df44298b47ba0e49a309fd76366875e8d18d73478252931713137844857b8ed5a
@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch from e3ec37b to 2f9249bCompareAugust 3, 2025 13:05
kwsantiago pushed a commit to privkeyio/bdk that referenced this pull request Aug 5, 2025
51ee99a docs(bitcoind_rpc): fixed typo in docs (Wei Chen)
73ab1eb chore(bitcoind_rpc): Make clippy happy (志宇)
7e894f4 feat(bitcoind_rpc)!: Use `getrawmempool` without verbose (志宇)
05464ec fix(bitcoind_rpc)!: Simplify emitter (志宇)
67dfb0b test(bitcoind_rpc): Detect new mempool txs (志宇)
Pull request description:
### Description
There is a bug in `bdk_bitcoind_rpc` where some new mempool transactions will not be emitted at all.
This problem exists because the avoid-re-emission logic depends on rounded-to-nearest-second timestamps.
The fix is to just emit all mempool transactions but wrap them in `Arc`s so that emission becomes cheap.
**Background:** I tried using `bdk_bitcoind_rpc` as the chain-source to write an example to showcase the [`IntentTracker`](bitcoindevkit/bdk_wallet#257). However, `bdk_bitcoind_rpc` failed to emit some mempool transactions.
### Notes to the reviewers
The test added in c22c68f fails without these fixes.
Some tests are removed as they are no longer relevant.
### Changelog notice
```md
Fixed:
- Some mempool transactions not being emitted at all. The fix is to replace the avoid-re-emission-logic with one which emits all mempool transactions.
```
### Checklists
#### All Submissions:
* [x] I've signed all my commits
* [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md)
* [x] I ran `cargo +nightly fmt` and `cargo clippy` before committing
#### Bugfixes:
* [x] This pull request breaks the existing API
* [x] I've added tests to reproduce the issue which are now passing
~* [ ] I'm linking the issue being fixed by this PR~
ACKs for top commit:
nymius:
cACK 51ee99a
LagginTimes:
Re-ACK 51ee99a
Tree-SHA512: 04e180e1d28c3f4c581a61ccac95e8e7e6927123d272ed07eae0ae51bf70799df44298b47ba0e49a309fd76366875e8d18d73478252931713137844857b8ed5a
@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch from 2f9249b to 5816070CompareAugust 7, 2025 08:39
@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch from 5816070 to 5d70885CompareAugust 29, 2025 01:30
@ovitrif

ovitrif commented Sep 8, 2025

Copy link
Copy Markdown

Hi guys, Bitkit team dev here, needing this to unlock:

Why?

EDIT: nvm, #6 is now fixed by #310. Thank you for your attention and collaboration 🙏🏻.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new featureNew feature or request

Projects

Archived in project

8 participants

@evanlinjin@coveralls@thunderbiscuit@notmandatory@tnull@nymius@ovitrif@ValuedMammal
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Introduce `IntentTracker` by evanlinjin · Pull Request #257 · bitcoindevkit/bdk_wallet · GitHub
Skip to content

Introduce IntentTracker - #257

Closed
evanlinjin wants to merge 7 commits into
bitcoindevkit:masterfrom
evanlinjin:feature/broadcast-queue
Closed

Introduce IntentTracker#257
evanlinjin wants to merge 7 commits into
bitcoindevkit:masterfrom
evanlinjin:feature/broadcast-queue

Conversation

@evanlinjin

@evanlinjinevanlinjin commented Jun 6, 2025

Copy link
Copy Markdown
Member

Fixes#166
Fixes#40
Fixed#295
Replaces #220

Description

Allows callers to spend from unbroadcasted transactions.

Notes to the reviewers

I think I may have done some overthinking for the BroadcastQueue implementation. This is the current implementation:

  • Wallet::add_tx_to_broadcast_queue will also remove conflicts (of the tx being inserted) from the broadcast queue.
  • Wallet::remove_tx_from_broadcast_queue will also remove descendants of the tx being removed.

However, I’m not convinced this feature is necessary, and it could lead to inconsistent behavior if callers sometimes use the BroadcastQueue, bypass it to broadcast transactions directly, or if multiple instances of the same wallet broadcast concurrently. In such cases—when intermediate transactions are missing from the queue—the logic described above will fail.

There is an argument for RBF, however, why would you need to RBF unbroadcasted transactions? It's better to empty the queue and start again.

Changelog notice

Checklists

To Get Out of Draft Status:

  • Have a section in the struct-level (Wallet) docs that explains the broadcast queue.
  • Better docs for each new method added.
  • Example: Wallet with single UTXO. Create x number of transactions sequentially. Broadcast all in one go. Sync.
  • Test persistence (sqlite).
  • More tests.

To Get This Merged:

All Submissions:

  • I've signed all my commits
  • I followed the contribution guidelines
  • I ran cargo +nightly fmt and cargo clippy before committing

New Features:

  • I've added tests for the new feature
  • I've added docs for the new feature

Bugfixes:

  • This pull request breaks the existing API
  • I've added tests to reproduce the issue which are now passing
  • I'm linking the issue being fixed by this PR

@coveralls

coveralls commented Jun 6, 2025

Copy link
Copy Markdown

Pull Request Test Coverage Report for Build 15943002368

Warning: This coverage report may be inaccurate.

This pull request's base commit is no longer the HEAD commit of its target branch. This means it includes changes from outside the original pull request, including, potentially, unrelated coverage changes.

Details

  • 213 of 632(33.7%) changed or added relevant lines in 5 files are covered.
  • 14 unchanged lines in 5 files lost coverage.
  • Overall coverage decreased (-4.9%) to 80.602%

Changes Missing CoverageCovered LinesChanged/Added Lines%
wallet/src/wallet/tx_builder.rs21020.0%
wallet/src/wallet/changeset.rs254160.98%
wallet/src/wallet/mod.rs13522260.81%
wallet/src/wallet/intent_tracker.rs4935713.73%
Files with Coverage ReductionNew Missed Lines%
wallet/src/descriptor/dsl.rs195.34%
wallet/src/wallet/changeset.rs279.44%
wallet/src/descriptor/policy.rs379.07%
wallet/src/descriptor/template.rs498.04%
wallet/src/wallet/mod.rs478.06%
TotalsCoverage Status
Change from base Build 15476130196:-4.9%
Covered Lines:6644
Relevant Lines:8243

💛 - Coveralls

@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch 2 times, most recently from 808bbdf to 75bc892CompareJune 6, 2025 10:27
@evanlinjinevanlinjin self-assigned this Jun 7, 2025
@notmandatorynotmandatory moved this to In Progress in BDK WalletJun 7, 2025
@notmandatorynotmandatory added the new feature New feature or request label Jun 7, 2025
Comment threadwallet/src/types.rs
/// The position of the output in the blockchain.
pub chain_position: ChainPosition<ConfirmationBlockTime>,
/// Whether this output exists in a transaction that is yet to be broadcasted.
pub needs_broadcast: bool,

@nymiusnymiusJun 8, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe an enum state field with something like: UNSPENT, ON_QUEUE, SPENT, BROADCASTED will avoid keep adding new boolean fields here, and provide a better path for update on future occasions, taking advantage of non exhaustive patters. is_spent could be marked for deprecation and be used along the new field in the meantime.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I like this idea if done as a non-exhaustive enum to help reduce future API breaking changes. If we include a "LOCKED" variant could this also support #259?

@nymiusnymius left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

However, I’m not convinced this feature is necessary, and it could lead to inconsistent behavior if callers sometimes use the BroadcastQueue, bypass it to broadcast transactions directly, or if multiple instances of the same wallet broadcast concurrently. In such cases—when intermediate transactions are missing from the queue—the logic described above will fail.

Can we enforce or support BroadcastQueue as the only way to broadcast transactions in bdk_wallet?
A user bypassing this mechanism should be considered? Are there reasons to not doing it?
Why would you keep broadcasting tx outside of the queue when you have an unbroadcasted tx in the queue?

I like the approach, and think is easy to reason about. Maybe we could leave the door open to implement other broadcast policies.
IMHO, the BroadcastQueue "profile" should ensure internal consistency, so I don't think is over engineered.

Why would you need to RBF unbroadcasted transactions?

Not a use case that I've needed, but maybe share multiple conflicting transactions offline looking for fee optimization in different scenarios.

@nymiusnymius mentioned this pull request Jun 8, 2025
7 tasks
@thunderbiscuit

thunderbiscuit commented Jun 9, 2025

Copy link
Copy Markdown
Member

Concept ACK. I like the idea of the queue.

I took a look at the diff and here are some thoughts/questions, pardon me if some of them would have been answered by doing a code deep dive, I just know you wanted early feedback so decided to get moving on it sooner than later.

  • Simple is good in my mind. If one of the requirements of the queue is that it's always internally valid and could in theory be broadcast all at once in one go, that's an easier mental model than allowing conflicts in the queue.
  • If the queue can actually have conflicts, it's less of a queue and more of a "bag" of transactions. Again less easy to reason about, and now the naming is misleading from the point of view of the users (I mean it's not that bad, I just mean it's not as neat/pure)
  • I like that the queue purges itself automatically on syncs.
  • I am potentially drawn to the idea from @nymius that all transactions could need to go in the queue first to then be broadcast. I wonder if that's an elegant way to force clean setups and handle the fact that a ton of wallets probably don't do costly sync every time they build transactions. That way the queue would always be aware of what has been broadcast. Does that complicate things too much? It would just be important that the library not have any footguns that would for example have you forget about a tx in the queue, persist it, then weeks later you just do Wallet::broadcast_queue and bam you just sent more than you wanted.

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for tackling this, took a first look.

I think I may have done some overthinking for the BroadcastQueue implementation. This is the current implementation:

Do we know how Core handles these things?

Also, when do we expect this queue to be processed? Would this happen manually or automatically in intervals?

Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
queue: VecDeque<Txid>,

/// Enforces that we do not have duplicates in `queue`.
dedup: HashSet<Txid>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I wonder if it's worth having this separate set? How many unbroadcasted transactions are we expecting at any given time? Maybe it would just be quicker to simply iterate over the queue itself, also saving the heap allocations/memory footprint?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

That is a good point. Maybe premature optimization here.

Comment threadwallet/src/types.rs
/// The position of the output in the blockchain.
pub chain_position: ChainPosition<ConfirmationBlockTime>,
/// Whether this output exists in a transaction that is yet to be broadcasted.
pub needs_broadcast: bool,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When do we expect this to be set/unset exactly? I guess it can only be unset once the transaction in question reaches threshold confirmations?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

That is a good point, and it shows the limitations of the BroadcastQueue concept. In fact, I did some further thinking on this and the BroadcastQueue should really be an IntentTracker and should track txs even if they are "network canonical".

There should be a method such as .tracked_txs_which_are_not_network_canonical (better name needed) so that the caller can decide to either replace the tx, or explicitly forget about it. There are caveats to doing both since we don't want to create a sub-graph where intended payments are duplicated - BDK should handle these situations properly, or provide the required information so that the caller can make a safe decision.

@notmandatory

notmandatory commented Jun 12, 2025

Copy link
Copy Markdown
Member

Do we know how Core handles these things?

@tnull do you mean how does the Core wallet handle un-broadcasted Tx and building new Tx on those un-broadcasted Tx outputs? As far as I know there are no features in the Core wallet for this beyond you the user holding on to your signed and un-broadcast Tx and manually building on those Tx outputs with the commands:

  1. createrawtransaction
  2. signrawtransactionwithwallet
  3. when you're ready to broadcast any of these Tx sendrawtransaction

@notmandatorynotmandatory left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Overall looks like a powerful new feature, I only have minor comments. Once you feel the API is ready I'd like to have a live chat to review it with L2 users like @tnull and @stevenroose to validate it meets their use cases.

Comment threadwallet/src/types.rs
/// The position of the output in the blockchain.
pub chain_position: ChainPosition<ConfirmationBlockTime>,
/// Whether this output exists in a transaction that is yet to be broadcasted.
pub needs_broadcast: bool,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I like this idea if done as a non-exhaustive enum to help reduce future API breaking changes. If we include a "LOCKED" variant could this also support #259?

Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
let tx = match tx_graph.get_tx(txid) {
Some(tx) => tx,
None => {
debug_assert!(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Would it be better to throw and Err here instead of the panic? It seems possible a user could mistakenly try to queue a Txid not in the tx_graph. Or is this ment to warn app devs that they should never let this happen?

I also don't understand why you only panic if the txid is not in the tx_graphand not in the dedup set. Isn't not having the Txid in the graph enough to panic due to it being invalid?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Sorry this was never meant to be in the public API. The idea is that we should only add txids into the BroadcastQueue which are also in TxGraph. If that is not the case, it is definitely an internal BDK error.

Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
@evanlinjin

evanlinjin commented Jun 15, 2025

Copy link
Copy Markdown
MemberAuthor

Can we enforce or support BroadcastQueue as the only way to broadcast transactions in bdk_wallet? A user bypassing this mechanism should be considered? Are there reasons to not doing it? Why would you keep broadcasting tx outside of the queue when you have unbroadcasted tx in the queue?

@nymius I've rethought about this problem. I think instead of a BroadcastQueue, it should really be an IntentTracker (refer to my comment here). Broadcast-ability can be evaluated on a trasaction-by-transaction basis. I do not think it is viable to enforce BroadcastQueue as the only way to broadcast transactions are BDK is not responsible for broadcasting directly to the mempool.

I like that the queue purges itself automatically on syncs.

@thunderbiscuit I agree that it is nice to reason with. However, I think it will introduce some footguns. Let me provide an example:

  • Transaction A (an intended payment) is broadcasted.
  • Transaction A gets evicted from the mempool so it disappears from the transaction list.
  • The user realizes this and creates a second transaction (B) to atone for the disappearance of transaction A. However, the coin selection puts A and B on non-conflicting subgraphs.

Now A and B can exist in the same history, and thus we have the potential birth of a double-payment situation.

My proposal right now, as mentioned above, is to have an IntentTracker which the user needs to explicitly forget or replace a "diverged" transaction.

The wallet will keep track of two consistent views of history:

  1. The canonical network view. This is what BDK assumes to be what the network sees. Currently wallet.transactions returns this.
  2. The canonical intent view. This is what the user intends to happen.

If these two views are the same, no action is required. If these two views diverge, the caller should be able to easily respond to it explicitly.

  • The tx merely needs a broadcast.
  • The tx is low fee so needs RBF/CPFP.
  • An input is no longer available. RBF?
  • Explicitly forgetting (this is safe if a conflict is x number of confirmations deep).

@evanlinjin

evanlinjin commented Jun 17, 2025

Copy link
Copy Markdown
MemberAuthor

When doing coin selection, we should use the "intent view" to obtain the UTXO set. This is to avoid accidentally double-spending intended-to-be-canonical transactions.

However, some intended-to-be-canonical transactions could not be canonical now (due to confirmed conflicts), or conflicts with mempool transactions (RBF, which may not go through in time).

So there should be some sort of filtering based on transactions in the IntentTracker:

  • Don't spend from transactions with confirmed conflicts.
  • Try to avoid spending from transactions with unconfirmed conflicts.
  • Try to avoid spending from unbroadcasted transactions.
  • Try to avoid spending from evicted transactions.

Of course, there are other filters that BDK does not do, but should really do (out of scope of this PR, but probably part of the same interface/structure):

  • Try to avoid spending from untrusted unconfirmed outputs (as they can be cancelled/replaced by another party).
  • Try to avoid spending from unconfirmed transactions in general.

@tnull

tnull commented Jun 18, 2025

Copy link
Copy Markdown
Contributor

Do we know how Core handles these things?

@tnull do you mean how does the Core wallet handle un-broadcasted Tx and building new Tx on those un-broadcasted Tx outputs? As far as I know there are no features in the Core wallet for this beyond you the user holding on to your signed and un-broadcast Tx and manually building on those Tx outputs with the commands:

1. [createrawtransaction](https://bitcoincore.org/en/doc/29.0.0/rpc/rawtransactions/createrawtransaction/)
2. [signrawtransactionwithwallet ](https://bitcoincore.org/en/doc/29.0.0/rpc/wallet/signrawtransactionwithwallet/)
3. when you're ready to broadcast any of these Tx [sendrawtransaction](https://bitcoincore.org/en/doc/29.0.0/rpc/rawtransactions/sendrawtransaction/)

Mh, right, regarding the UTXO locking usecase, it does feature a rather simple interface through lockunspent / listlockunspent though. As said on #166, that (mod maybe an auto-unlock feature) would likely be all we'd really need on our end for now, I think.

@nymius

nymius commented Jun 19, 2025

Copy link
Copy Markdown
Contributor

Thanks for modeling this, from my perspective, it resembles to React DOM and virtual DOM, and its reconciliation model.

The user realizes this and creates a second transaction (B) to atone for the disappearance of transaction A. However, the coin selection puts A and B on non-conflicting subgraphs.

A quick check: when you say non-conflicting subgraphs, it is implied B is not spending any inputs from A, but is spending to the same outputs, right?

My proposal right now, as mentioned above, is to have an IntentTracker which the user needs to explicitly forget or replace a "diverged" transaction.

Do you have in mind some diff method between this IntentTracker and the canonical to find these divergences?
For the updates, all these actions you mentioned (re-broadcast, rbf, cpfp, forget) will be implemented as IntentTrackers methods?


The canonical network view. This is what BDK assumes to be what the network sees. Currently wallet.transactions returns this.

I'm confused here, Wallet.transactions docs say the following:

/// Iterate over relevant and canonical transactions in the wallet.
///
/// A transaction is relevant when it spends from or spends to at least one tracked output. A
/// transaction is canonical when it is confirmed in the best chain, or does not conflict
/// with any transaction confirmed in the best chain.

My guess is relevant transactions should be left out of the equation here.

@notmandatorynotmandatory modified the milestone: Wallet 3.0.0Jun 25, 2025
@evanlinjinevanlinjin changed the title Introduce BroadcastQueueIntroduce IntentTrackerJun 28, 2025
This was referenced Jul 4, 2025
evanlinjin added a commit to bitcoindevkit/bdk that referenced this pull request Jul 26, 2025
51ee99a docs(bitcoind_rpc): fixed typo in docs (Wei Chen)
73ab1eb chore(bitcoind_rpc): Make clippy happy (志宇)
7e894f4 feat(bitcoind_rpc)!: Use `getrawmempool` without verbose (志宇)
05464ec fix(bitcoind_rpc)!: Simplify emitter (志宇)
67dfb0b test(bitcoind_rpc): Detect new mempool txs (志宇)
Pull request description:
### Description
There is a bug in `bdk_bitcoind_rpc` where some new mempool transactions will not be emitted at all.
This problem exists because the avoid-re-emission logic depends on rounded-to-nearest-second timestamps.
The fix is to just emit all mempool transactions but wrap them in `Arc`s so that emission becomes cheap.
**Background:** I tried using `bdk_bitcoind_rpc` as the chain-source to write an example to showcase the [`IntentTracker`](bitcoindevkit/bdk_wallet#257). However, `bdk_bitcoind_rpc` failed to emit some mempool transactions.
### Notes to the reviewers
The test added in c22c68f fails without these fixes.
Some tests are removed as they are no longer relevant.
### Changelog notice
```md
Fixed:
- Some mempool transactions not being emitted at all. The fix is to replace the avoid-re-emission-logic with one which emits all mempool transactions.
```
### Checklists
#### All Submissions:
* [x] I've signed all my commits
* [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md)
* [x] I ran `cargo +nightly fmt` and `cargo clippy` before committing
#### Bugfixes:
* [x] This pull request breaks the existing API
* [x] I've added tests to reproduce the issue which are now passing
~* [ ] I'm linking the issue being fixed by this PR~
ACKs for top commit:
nymius:
cACK 51ee99a
LagginTimes:
Re-ACK 51ee99a
Tree-SHA512: 04e180e1d28c3f4c581a61ccac95e8e7e6927123d272ed07eae0ae51bf70799df44298b47ba0e49a309fd76366875e8d18d73478252931713137844857b8ed5a
@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch from e3ec37b to 2f9249bCompareAugust 3, 2025 13:05
kwsantiago pushed a commit to privkeyio/bdk that referenced this pull request Aug 5, 2025
51ee99a docs(bitcoind_rpc): fixed typo in docs (Wei Chen)
73ab1eb chore(bitcoind_rpc): Make clippy happy (志宇)
7e894f4 feat(bitcoind_rpc)!: Use `getrawmempool` without verbose (志宇)
05464ec fix(bitcoind_rpc)!: Simplify emitter (志宇)
67dfb0b test(bitcoind_rpc): Detect new mempool txs (志宇)
Pull request description:
### Description
There is a bug in `bdk_bitcoind_rpc` where some new mempool transactions will not be emitted at all.
This problem exists because the avoid-re-emission logic depends on rounded-to-nearest-second timestamps.
The fix is to just emit all mempool transactions but wrap them in `Arc`s so that emission becomes cheap.
**Background:** I tried using `bdk_bitcoind_rpc` as the chain-source to write an example to showcase the [`IntentTracker`](bitcoindevkit/bdk_wallet#257). However, `bdk_bitcoind_rpc` failed to emit some mempool transactions.
### Notes to the reviewers
The test added in c22c68f fails without these fixes.
Some tests are removed as they are no longer relevant.
### Changelog notice
```md
Fixed:
- Some mempool transactions not being emitted at all. The fix is to replace the avoid-re-emission-logic with one which emits all mempool transactions.
```
### Checklists
#### All Submissions:
* [x] I've signed all my commits
* [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md)
* [x] I ran `cargo +nightly fmt` and `cargo clippy` before committing
#### Bugfixes:
* [x] This pull request breaks the existing API
* [x] I've added tests to reproduce the issue which are now passing
~* [ ] I'm linking the issue being fixed by this PR~
ACKs for top commit:
nymius:
cACK 51ee99a
LagginTimes:
Re-ACK 51ee99a
Tree-SHA512: 04e180e1d28c3f4c581a61ccac95e8e7e6927123d272ed07eae0ae51bf70799df44298b47ba0e49a309fd76366875e8d18d73478252931713137844857b8ed5a
@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch from 2f9249b to 5816070CompareAugust 7, 2025 08:39
@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch from 5816070 to 5d70885CompareAugust 29, 2025 01:30
@ovitrif

ovitrif commented Sep 8, 2025

Copy link
Copy Markdown

Hi guys, Bitkit team dev here, needing this to unlock:

Why?

EDIT: nvm, #6 is now fixed by #310. Thank you for your attention and collaboration 🙏🏻.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new featureNew feature or request

Projects

Archived in project

8 participants

@evanlinjin@coveralls@thunderbiscuit@notmandatory@tnull@nymius@ovitrif@ValuedMammal
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Introduce `IntentTracker` by evanlinjin · Pull Request #257 · bitcoindevkit/bdk_wallet · GitHub
Skip to content

Introduce IntentTracker - #257

Closed
evanlinjin wants to merge 7 commits into
bitcoindevkit:masterfrom
evanlinjin:feature/broadcast-queue
Closed

Introduce IntentTracker#257
evanlinjin wants to merge 7 commits into
bitcoindevkit:masterfrom
evanlinjin:feature/broadcast-queue

Conversation

@evanlinjin

@evanlinjinevanlinjin commented Jun 6, 2025

Copy link
Copy Markdown
Member

Fixes#166
Fixes#40
Fixed#295
Replaces #220

Description

Allows callers to spend from unbroadcasted transactions.

Notes to the reviewers

I think I may have done some overthinking for the BroadcastQueue implementation. This is the current implementation:

  • Wallet::add_tx_to_broadcast_queue will also remove conflicts (of the tx being inserted) from the broadcast queue.
  • Wallet::remove_tx_from_broadcast_queue will also remove descendants of the tx being removed.

However, I’m not convinced this feature is necessary, and it could lead to inconsistent behavior if callers sometimes use the BroadcastQueue, bypass it to broadcast transactions directly, or if multiple instances of the same wallet broadcast concurrently. In such cases—when intermediate transactions are missing from the queue—the logic described above will fail.

There is an argument for RBF, however, why would you need to RBF unbroadcasted transactions? It's better to empty the queue and start again.

Changelog notice

Checklists

To Get Out of Draft Status:

  • Have a section in the struct-level (Wallet) docs that explains the broadcast queue.
  • Better docs for each new method added.
  • Example: Wallet with single UTXO. Create x number of transactions sequentially. Broadcast all in one go. Sync.
  • Test persistence (sqlite).
  • More tests.

To Get This Merged:

All Submissions:

  • I've signed all my commits
  • I followed the contribution guidelines
  • I ran cargo +nightly fmt and cargo clippy before committing

New Features:

  • I've added tests for the new feature
  • I've added docs for the new feature

Bugfixes:

  • This pull request breaks the existing API
  • I've added tests to reproduce the issue which are now passing
  • I'm linking the issue being fixed by this PR

@coveralls

coveralls commented Jun 6, 2025

Copy link
Copy Markdown

Pull Request Test Coverage Report for Build 15943002368

Warning: This coverage report may be inaccurate.

This pull request's base commit is no longer the HEAD commit of its target branch. This means it includes changes from outside the original pull request, including, potentially, unrelated coverage changes.

Details

  • 213 of 632(33.7%) changed or added relevant lines in 5 files are covered.
  • 14 unchanged lines in 5 files lost coverage.
  • Overall coverage decreased (-4.9%) to 80.602%

Changes Missing CoverageCovered LinesChanged/Added Lines%
wallet/src/wallet/tx_builder.rs21020.0%
wallet/src/wallet/changeset.rs254160.98%
wallet/src/wallet/mod.rs13522260.81%
wallet/src/wallet/intent_tracker.rs4935713.73%
Files with Coverage ReductionNew Missed Lines%
wallet/src/descriptor/dsl.rs195.34%
wallet/src/wallet/changeset.rs279.44%
wallet/src/descriptor/policy.rs379.07%
wallet/src/descriptor/template.rs498.04%
wallet/src/wallet/mod.rs478.06%
TotalsCoverage Status
Change from base Build 15476130196:-4.9%
Covered Lines:6644
Relevant Lines:8243

💛 - Coveralls

@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch 2 times, most recently from 808bbdf to 75bc892CompareJune 6, 2025 10:27
@evanlinjinevanlinjin self-assigned this Jun 7, 2025
@notmandatorynotmandatory moved this to In Progress in BDK WalletJun 7, 2025
@notmandatorynotmandatory added the new feature New feature or request label Jun 7, 2025
Comment threadwallet/src/types.rs
/// The position of the output in the blockchain.
pub chain_position: ChainPosition<ConfirmationBlockTime>,
/// Whether this output exists in a transaction that is yet to be broadcasted.
pub needs_broadcast: bool,

@nymiusnymiusJun 8, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe an enum state field with something like: UNSPENT, ON_QUEUE, SPENT, BROADCASTED will avoid keep adding new boolean fields here, and provide a better path for update on future occasions, taking advantage of non exhaustive patters. is_spent could be marked for deprecation and be used along the new field in the meantime.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I like this idea if done as a non-exhaustive enum to help reduce future API breaking changes. If we include a "LOCKED" variant could this also support #259?

@nymiusnymius left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

However, I’m not convinced this feature is necessary, and it could lead to inconsistent behavior if callers sometimes use the BroadcastQueue, bypass it to broadcast transactions directly, or if multiple instances of the same wallet broadcast concurrently. In such cases—when intermediate transactions are missing from the queue—the logic described above will fail.

Can we enforce or support BroadcastQueue as the only way to broadcast transactions in bdk_wallet?
A user bypassing this mechanism should be considered? Are there reasons to not doing it?
Why would you keep broadcasting tx outside of the queue when you have an unbroadcasted tx in the queue?

I like the approach, and think is easy to reason about. Maybe we could leave the door open to implement other broadcast policies.
IMHO, the BroadcastQueue "profile" should ensure internal consistency, so I don't think is over engineered.

Why would you need to RBF unbroadcasted transactions?

Not a use case that I've needed, but maybe share multiple conflicting transactions offline looking for fee optimization in different scenarios.

@nymiusnymius mentioned this pull request Jun 8, 2025
7 tasks
@thunderbiscuit

thunderbiscuit commented Jun 9, 2025

Copy link
Copy Markdown
Member

Concept ACK. I like the idea of the queue.

I took a look at the diff and here are some thoughts/questions, pardon me if some of them would have been answered by doing a code deep dive, I just know you wanted early feedback so decided to get moving on it sooner than later.

  • Simple is good in my mind. If one of the requirements of the queue is that it's always internally valid and could in theory be broadcast all at once in one go, that's an easier mental model than allowing conflicts in the queue.
  • If the queue can actually have conflicts, it's less of a queue and more of a "bag" of transactions. Again less easy to reason about, and now the naming is misleading from the point of view of the users (I mean it's not that bad, I just mean it's not as neat/pure)
  • I like that the queue purges itself automatically on syncs.
  • I am potentially drawn to the idea from @nymius that all transactions could need to go in the queue first to then be broadcast. I wonder if that's an elegant way to force clean setups and handle the fact that a ton of wallets probably don't do costly sync every time they build transactions. That way the queue would always be aware of what has been broadcast. Does that complicate things too much? It would just be important that the library not have any footguns that would for example have you forget about a tx in the queue, persist it, then weeks later you just do Wallet::broadcast_queue and bam you just sent more than you wanted.

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for tackling this, took a first look.

I think I may have done some overthinking for the BroadcastQueue implementation. This is the current implementation:

Do we know how Core handles these things?

Also, when do we expect this queue to be processed? Would this happen manually or automatically in intervals?

Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
queue: VecDeque<Txid>,

/// Enforces that we do not have duplicates in `queue`.
dedup: HashSet<Txid>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I wonder if it's worth having this separate set? How many unbroadcasted transactions are we expecting at any given time? Maybe it would just be quicker to simply iterate over the queue itself, also saving the heap allocations/memory footprint?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

That is a good point. Maybe premature optimization here.

Comment threadwallet/src/types.rs
/// The position of the output in the blockchain.
pub chain_position: ChainPosition<ConfirmationBlockTime>,
/// Whether this output exists in a transaction that is yet to be broadcasted.
pub needs_broadcast: bool,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When do we expect this to be set/unset exactly? I guess it can only be unset once the transaction in question reaches threshold confirmations?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

That is a good point, and it shows the limitations of the BroadcastQueue concept. In fact, I did some further thinking on this and the BroadcastQueue should really be an IntentTracker and should track txs even if they are "network canonical".

There should be a method such as .tracked_txs_which_are_not_network_canonical (better name needed) so that the caller can decide to either replace the tx, or explicitly forget about it. There are caveats to doing both since we don't want to create a sub-graph where intended payments are duplicated - BDK should handle these situations properly, or provide the required information so that the caller can make a safe decision.

@notmandatory

notmandatory commented Jun 12, 2025

Copy link
Copy Markdown
Member

Do we know how Core handles these things?

@tnull do you mean how does the Core wallet handle un-broadcasted Tx and building new Tx on those un-broadcasted Tx outputs? As far as I know there are no features in the Core wallet for this beyond you the user holding on to your signed and un-broadcast Tx and manually building on those Tx outputs with the commands:

  1. createrawtransaction
  2. signrawtransactionwithwallet
  3. when you're ready to broadcast any of these Tx sendrawtransaction

@notmandatorynotmandatory left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Overall looks like a powerful new feature, I only have minor comments. Once you feel the API is ready I'd like to have a live chat to review it with L2 users like @tnull and @stevenroose to validate it meets their use cases.

Comment threadwallet/src/types.rs
/// The position of the output in the blockchain.
pub chain_position: ChainPosition<ConfirmationBlockTime>,
/// Whether this output exists in a transaction that is yet to be broadcasted.
pub needs_broadcast: bool,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I like this idea if done as a non-exhaustive enum to help reduce future API breaking changes. If we include a "LOCKED" variant could this also support #259?

Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
let tx = match tx_graph.get_tx(txid) {
Some(tx) => tx,
None => {
debug_assert!(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Would it be better to throw and Err here instead of the panic? It seems possible a user could mistakenly try to queue a Txid not in the tx_graph. Or is this ment to warn app devs that they should never let this happen?

I also don't understand why you only panic if the txid is not in the tx_graphand not in the dedup set. Isn't not having the Txid in the graph enough to panic due to it being invalid?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Sorry this was never meant to be in the public API. The idea is that we should only add txids into the BroadcastQueue which are also in TxGraph. If that is not the case, it is definitely an internal BDK error.

Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
@evanlinjin

evanlinjin commented Jun 15, 2025

Copy link
Copy Markdown
MemberAuthor

Can we enforce or support BroadcastQueue as the only way to broadcast transactions in bdk_wallet? A user bypassing this mechanism should be considered? Are there reasons to not doing it? Why would you keep broadcasting tx outside of the queue when you have unbroadcasted tx in the queue?

@nymius I've rethought about this problem. I think instead of a BroadcastQueue, it should really be an IntentTracker (refer to my comment here). Broadcast-ability can be evaluated on a trasaction-by-transaction basis. I do not think it is viable to enforce BroadcastQueue as the only way to broadcast transactions are BDK is not responsible for broadcasting directly to the mempool.

I like that the queue purges itself automatically on syncs.

@thunderbiscuit I agree that it is nice to reason with. However, I think it will introduce some footguns. Let me provide an example:

  • Transaction A (an intended payment) is broadcasted.
  • Transaction A gets evicted from the mempool so it disappears from the transaction list.
  • The user realizes this and creates a second transaction (B) to atone for the disappearance of transaction A. However, the coin selection puts A and B on non-conflicting subgraphs.

Now A and B can exist in the same history, and thus we have the potential birth of a double-payment situation.

My proposal right now, as mentioned above, is to have an IntentTracker which the user needs to explicitly forget or replace a "diverged" transaction.

The wallet will keep track of two consistent views of history:

  1. The canonical network view. This is what BDK assumes to be what the network sees. Currently wallet.transactions returns this.
  2. The canonical intent view. This is what the user intends to happen.

If these two views are the same, no action is required. If these two views diverge, the caller should be able to easily respond to it explicitly.

  • The tx merely needs a broadcast.
  • The tx is low fee so needs RBF/CPFP.
  • An input is no longer available. RBF?
  • Explicitly forgetting (this is safe if a conflict is x number of confirmations deep).

@evanlinjin

evanlinjin commented Jun 17, 2025

Copy link
Copy Markdown
MemberAuthor

When doing coin selection, we should use the "intent view" to obtain the UTXO set. This is to avoid accidentally double-spending intended-to-be-canonical transactions.

However, some intended-to-be-canonical transactions could not be canonical now (due to confirmed conflicts), or conflicts with mempool transactions (RBF, which may not go through in time).

So there should be some sort of filtering based on transactions in the IntentTracker:

  • Don't spend from transactions with confirmed conflicts.
  • Try to avoid spending from transactions with unconfirmed conflicts.
  • Try to avoid spending from unbroadcasted transactions.
  • Try to avoid spending from evicted transactions.

Of course, there are other filters that BDK does not do, but should really do (out of scope of this PR, but probably part of the same interface/structure):

  • Try to avoid spending from untrusted unconfirmed outputs (as they can be cancelled/replaced by another party).
  • Try to avoid spending from unconfirmed transactions in general.

@tnull

tnull commented Jun 18, 2025

Copy link
Copy Markdown
Contributor

Do we know how Core handles these things?

@tnull do you mean how does the Core wallet handle un-broadcasted Tx and building new Tx on those un-broadcasted Tx outputs? As far as I know there are no features in the Core wallet for this beyond you the user holding on to your signed and un-broadcast Tx and manually building on those Tx outputs with the commands:

1. [createrawtransaction](https://bitcoincore.org/en/doc/29.0.0/rpc/rawtransactions/createrawtransaction/)
2. [signrawtransactionwithwallet ](https://bitcoincore.org/en/doc/29.0.0/rpc/wallet/signrawtransactionwithwallet/)
3. when you're ready to broadcast any of these Tx [sendrawtransaction](https://bitcoincore.org/en/doc/29.0.0/rpc/rawtransactions/sendrawtransaction/)

Mh, right, regarding the UTXO locking usecase, it does feature a rather simple interface through lockunspent / listlockunspent though. As said on #166, that (mod maybe an auto-unlock feature) would likely be all we'd really need on our end for now, I think.

@nymius

nymius commented Jun 19, 2025

Copy link
Copy Markdown
Contributor

Thanks for modeling this, from my perspective, it resembles to React DOM and virtual DOM, and its reconciliation model.

The user realizes this and creates a second transaction (B) to atone for the disappearance of transaction A. However, the coin selection puts A and B on non-conflicting subgraphs.

A quick check: when you say non-conflicting subgraphs, it is implied B is not spending any inputs from A, but is spending to the same outputs, right?

My proposal right now, as mentioned above, is to have an IntentTracker which the user needs to explicitly forget or replace a "diverged" transaction.

Do you have in mind some diff method between this IntentTracker and the canonical to find these divergences?
For the updates, all these actions you mentioned (re-broadcast, rbf, cpfp, forget) will be implemented as IntentTrackers methods?


The canonical network view. This is what BDK assumes to be what the network sees. Currently wallet.transactions returns this.

I'm confused here, Wallet.transactions docs say the following:

/// Iterate over relevant and canonical transactions in the wallet.
///
/// A transaction is relevant when it spends from or spends to at least one tracked output. A
/// transaction is canonical when it is confirmed in the best chain, or does not conflict
/// with any transaction confirmed in the best chain.

My guess is relevant transactions should be left out of the equation here.

@notmandatorynotmandatory modified the milestone: Wallet 3.0.0Jun 25, 2025
@evanlinjinevanlinjin changed the title Introduce BroadcastQueueIntroduce IntentTrackerJun 28, 2025
This was referenced Jul 4, 2025
evanlinjin added a commit to bitcoindevkit/bdk that referenced this pull request Jul 26, 2025
51ee99a docs(bitcoind_rpc): fixed typo in docs (Wei Chen)
73ab1eb chore(bitcoind_rpc): Make clippy happy (志宇)
7e894f4 feat(bitcoind_rpc)!: Use `getrawmempool` without verbose (志宇)
05464ec fix(bitcoind_rpc)!: Simplify emitter (志宇)
67dfb0b test(bitcoind_rpc): Detect new mempool txs (志宇)
Pull request description:
### Description
There is a bug in `bdk_bitcoind_rpc` where some new mempool transactions will not be emitted at all.
This problem exists because the avoid-re-emission logic depends on rounded-to-nearest-second timestamps.
The fix is to just emit all mempool transactions but wrap them in `Arc`s so that emission becomes cheap.
**Background:** I tried using `bdk_bitcoind_rpc` as the chain-source to write an example to showcase the [`IntentTracker`](bitcoindevkit/bdk_wallet#257). However, `bdk_bitcoind_rpc` failed to emit some mempool transactions.
### Notes to the reviewers
The test added in c22c68f fails without these fixes.
Some tests are removed as they are no longer relevant.
### Changelog notice
```md
Fixed:
- Some mempool transactions not being emitted at all. The fix is to replace the avoid-re-emission-logic with one which emits all mempool transactions.
```
### Checklists
#### All Submissions:
* [x] I've signed all my commits
* [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md)
* [x] I ran `cargo +nightly fmt` and `cargo clippy` before committing
#### Bugfixes:
* [x] This pull request breaks the existing API
* [x] I've added tests to reproduce the issue which are now passing
~* [ ] I'm linking the issue being fixed by this PR~
ACKs for top commit:
nymius:
cACK 51ee99a
LagginTimes:
Re-ACK 51ee99a
Tree-SHA512: 04e180e1d28c3f4c581a61ccac95e8e7e6927123d272ed07eae0ae51bf70799df44298b47ba0e49a309fd76366875e8d18d73478252931713137844857b8ed5a
@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch from e3ec37b to 2f9249bCompareAugust 3, 2025 13:05
kwsantiago pushed a commit to privkeyio/bdk that referenced this pull request Aug 5, 2025
51ee99a docs(bitcoind_rpc): fixed typo in docs (Wei Chen)
73ab1eb chore(bitcoind_rpc): Make clippy happy (志宇)
7e894f4 feat(bitcoind_rpc)!: Use `getrawmempool` without verbose (志宇)
05464ec fix(bitcoind_rpc)!: Simplify emitter (志宇)
67dfb0b test(bitcoind_rpc): Detect new mempool txs (志宇)
Pull request description:
### Description
There is a bug in `bdk_bitcoind_rpc` where some new mempool transactions will not be emitted at all.
This problem exists because the avoid-re-emission logic depends on rounded-to-nearest-second timestamps.
The fix is to just emit all mempool transactions but wrap them in `Arc`s so that emission becomes cheap.
**Background:** I tried using `bdk_bitcoind_rpc` as the chain-source to write an example to showcase the [`IntentTracker`](bitcoindevkit/bdk_wallet#257). However, `bdk_bitcoind_rpc` failed to emit some mempool transactions.
### Notes to the reviewers
The test added in c22c68f fails without these fixes.
Some tests are removed as they are no longer relevant.
### Changelog notice
```md
Fixed:
- Some mempool transactions not being emitted at all. The fix is to replace the avoid-re-emission-logic with one which emits all mempool transactions.
```
### Checklists
#### All Submissions:
* [x] I've signed all my commits
* [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md)
* [x] I ran `cargo +nightly fmt` and `cargo clippy` before committing
#### Bugfixes:
* [x] This pull request breaks the existing API
* [x] I've added tests to reproduce the issue which are now passing
~* [ ] I'm linking the issue being fixed by this PR~
ACKs for top commit:
nymius:
cACK 51ee99a
LagginTimes:
Re-ACK 51ee99a
Tree-SHA512: 04e180e1d28c3f4c581a61ccac95e8e7e6927123d272ed07eae0ae51bf70799df44298b47ba0e49a309fd76366875e8d18d73478252931713137844857b8ed5a
@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch from 2f9249b to 5816070CompareAugust 7, 2025 08:39
@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch from 5816070 to 5d70885CompareAugust 29, 2025 01:30
@ovitrif

ovitrif commented Sep 8, 2025

Copy link
Copy Markdown

Hi guys, Bitkit team dev here, needing this to unlock:

Why?

EDIT: nvm, #6 is now fixed by #310. Thank you for your attention and collaboration 🙏🏻.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new featureNew feature or request

Projects

Archived in project

8 participants

@evanlinjin@coveralls@thunderbiscuit@notmandatory@tnull@nymius@ovitrif@ValuedMammal
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Introduce `IntentTracker` by evanlinjin · Pull Request #257 · bitcoindevkit/bdk_wallet · GitHub
Skip to content

Introduce IntentTracker - #257

Closed
evanlinjin wants to merge 7 commits into
bitcoindevkit:masterfrom
evanlinjin:feature/broadcast-queue
Closed

Introduce IntentTracker#257
evanlinjin wants to merge 7 commits into
bitcoindevkit:masterfrom
evanlinjin:feature/broadcast-queue

Conversation

@evanlinjin

@evanlinjinevanlinjin commented Jun 6, 2025

Copy link
Copy Markdown
Member

Fixes#166
Fixes#40
Fixed#295
Replaces #220

Description

Allows callers to spend from unbroadcasted transactions.

Notes to the reviewers

I think I may have done some overthinking for the BroadcastQueue implementation. This is the current implementation:

  • Wallet::add_tx_to_broadcast_queue will also remove conflicts (of the tx being inserted) from the broadcast queue.
  • Wallet::remove_tx_from_broadcast_queue will also remove descendants of the tx being removed.

However, I’m not convinced this feature is necessary, and it could lead to inconsistent behavior if callers sometimes use the BroadcastQueue, bypass it to broadcast transactions directly, or if multiple instances of the same wallet broadcast concurrently. In such cases—when intermediate transactions are missing from the queue—the logic described above will fail.

There is an argument for RBF, however, why would you need to RBF unbroadcasted transactions? It's better to empty the queue and start again.

Changelog notice

Checklists

To Get Out of Draft Status:

  • Have a section in the struct-level (Wallet) docs that explains the broadcast queue.
  • Better docs for each new method added.
  • Example: Wallet with single UTXO. Create x number of transactions sequentially. Broadcast all in one go. Sync.
  • Test persistence (sqlite).
  • More tests.

To Get This Merged:

All Submissions:

  • I've signed all my commits
  • I followed the contribution guidelines
  • I ran cargo +nightly fmt and cargo clippy before committing

New Features:

  • I've added tests for the new feature
  • I've added docs for the new feature

Bugfixes:

  • This pull request breaks the existing API
  • I've added tests to reproduce the issue which are now passing
  • I'm linking the issue being fixed by this PR

@coveralls

coveralls commented Jun 6, 2025

Copy link
Copy Markdown

Pull Request Test Coverage Report for Build 15943002368

Warning: This coverage report may be inaccurate.

This pull request's base commit is no longer the HEAD commit of its target branch. This means it includes changes from outside the original pull request, including, potentially, unrelated coverage changes.

Details

  • 213 of 632(33.7%) changed or added relevant lines in 5 files are covered.
  • 14 unchanged lines in 5 files lost coverage.
  • Overall coverage decreased (-4.9%) to 80.602%

Changes Missing CoverageCovered LinesChanged/Added Lines%
wallet/src/wallet/tx_builder.rs21020.0%
wallet/src/wallet/changeset.rs254160.98%
wallet/src/wallet/mod.rs13522260.81%
wallet/src/wallet/intent_tracker.rs4935713.73%
Files with Coverage ReductionNew Missed Lines%
wallet/src/descriptor/dsl.rs195.34%
wallet/src/wallet/changeset.rs279.44%
wallet/src/descriptor/policy.rs379.07%
wallet/src/descriptor/template.rs498.04%
wallet/src/wallet/mod.rs478.06%
TotalsCoverage Status
Change from base Build 15476130196:-4.9%
Covered Lines:6644
Relevant Lines:8243

💛 - Coveralls

@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch 2 times, most recently from 808bbdf to 75bc892CompareJune 6, 2025 10:27
@evanlinjinevanlinjin self-assigned this Jun 7, 2025
@notmandatorynotmandatory moved this to In Progress in BDK WalletJun 7, 2025
@notmandatorynotmandatory added the new feature New feature or request label Jun 7, 2025
Comment threadwallet/src/types.rs
/// The position of the output in the blockchain.
pub chain_position: ChainPosition<ConfirmationBlockTime>,
/// Whether this output exists in a transaction that is yet to be broadcasted.
pub needs_broadcast: bool,

@nymiusnymiusJun 8, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe an enum state field with something like: UNSPENT, ON_QUEUE, SPENT, BROADCASTED will avoid keep adding new boolean fields here, and provide a better path for update on future occasions, taking advantage of non exhaustive patters. is_spent could be marked for deprecation and be used along the new field in the meantime.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I like this idea if done as a non-exhaustive enum to help reduce future API breaking changes. If we include a "LOCKED" variant could this also support #259?

@nymiusnymius left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

However, I’m not convinced this feature is necessary, and it could lead to inconsistent behavior if callers sometimes use the BroadcastQueue, bypass it to broadcast transactions directly, or if multiple instances of the same wallet broadcast concurrently. In such cases—when intermediate transactions are missing from the queue—the logic described above will fail.

Can we enforce or support BroadcastQueue as the only way to broadcast transactions in bdk_wallet?
A user bypassing this mechanism should be considered? Are there reasons to not doing it?
Why would you keep broadcasting tx outside of the queue when you have an unbroadcasted tx in the queue?

I like the approach, and think is easy to reason about. Maybe we could leave the door open to implement other broadcast policies.
IMHO, the BroadcastQueue "profile" should ensure internal consistency, so I don't think is over engineered.

Why would you need to RBF unbroadcasted transactions?

Not a use case that I've needed, but maybe share multiple conflicting transactions offline looking for fee optimization in different scenarios.

@nymiusnymius mentioned this pull request Jun 8, 2025
7 tasks
@thunderbiscuit

thunderbiscuit commented Jun 9, 2025

Copy link
Copy Markdown
Member

Concept ACK. I like the idea of the queue.

I took a look at the diff and here are some thoughts/questions, pardon me if some of them would have been answered by doing a code deep dive, I just know you wanted early feedback so decided to get moving on it sooner than later.

  • Simple is good in my mind. If one of the requirements of the queue is that it's always internally valid and could in theory be broadcast all at once in one go, that's an easier mental model than allowing conflicts in the queue.
  • If the queue can actually have conflicts, it's less of a queue and more of a "bag" of transactions. Again less easy to reason about, and now the naming is misleading from the point of view of the users (I mean it's not that bad, I just mean it's not as neat/pure)
  • I like that the queue purges itself automatically on syncs.
  • I am potentially drawn to the idea from @nymius that all transactions could need to go in the queue first to then be broadcast. I wonder if that's an elegant way to force clean setups and handle the fact that a ton of wallets probably don't do costly sync every time they build transactions. That way the queue would always be aware of what has been broadcast. Does that complicate things too much? It would just be important that the library not have any footguns that would for example have you forget about a tx in the queue, persist it, then weeks later you just do Wallet::broadcast_queue and bam you just sent more than you wanted.

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for tackling this, took a first look.

I think I may have done some overthinking for the BroadcastQueue implementation. This is the current implementation:

Do we know how Core handles these things?

Also, when do we expect this queue to be processed? Would this happen manually or automatically in intervals?

Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
queue: VecDeque<Txid>,

/// Enforces that we do not have duplicates in `queue`.
dedup: HashSet<Txid>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I wonder if it's worth having this separate set? How many unbroadcasted transactions are we expecting at any given time? Maybe it would just be quicker to simply iterate over the queue itself, also saving the heap allocations/memory footprint?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

That is a good point. Maybe premature optimization here.

Comment threadwallet/src/types.rs
/// The position of the output in the blockchain.
pub chain_position: ChainPosition<ConfirmationBlockTime>,
/// Whether this output exists in a transaction that is yet to be broadcasted.
pub needs_broadcast: bool,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When do we expect this to be set/unset exactly? I guess it can only be unset once the transaction in question reaches threshold confirmations?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

That is a good point, and it shows the limitations of the BroadcastQueue concept. In fact, I did some further thinking on this and the BroadcastQueue should really be an IntentTracker and should track txs even if they are "network canonical".

There should be a method such as .tracked_txs_which_are_not_network_canonical (better name needed) so that the caller can decide to either replace the tx, or explicitly forget about it. There are caveats to doing both since we don't want to create a sub-graph where intended payments are duplicated - BDK should handle these situations properly, or provide the required information so that the caller can make a safe decision.

@notmandatory

notmandatory commented Jun 12, 2025

Copy link
Copy Markdown
Member

Do we know how Core handles these things?

@tnull do you mean how does the Core wallet handle un-broadcasted Tx and building new Tx on those un-broadcasted Tx outputs? As far as I know there are no features in the Core wallet for this beyond you the user holding on to your signed and un-broadcast Tx and manually building on those Tx outputs with the commands:

  1. createrawtransaction
  2. signrawtransactionwithwallet
  3. when you're ready to broadcast any of these Tx sendrawtransaction

@notmandatorynotmandatory left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Overall looks like a powerful new feature, I only have minor comments. Once you feel the API is ready I'd like to have a live chat to review it with L2 users like @tnull and @stevenroose to validate it meets their use cases.

Comment threadwallet/src/types.rs
/// The position of the output in the blockchain.
pub chain_position: ChainPosition<ConfirmationBlockTime>,
/// Whether this output exists in a transaction that is yet to be broadcasted.
pub needs_broadcast: bool,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I like this idea if done as a non-exhaustive enum to help reduce future API breaking changes. If we include a "LOCKED" variant could this also support #259?

Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
let tx = match tx_graph.get_tx(txid) {
Some(tx) => tx,
None => {
debug_assert!(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Would it be better to throw and Err here instead of the panic? It seems possible a user could mistakenly try to queue a Txid not in the tx_graph. Or is this ment to warn app devs that they should never let this happen?

I also don't understand why you only panic if the txid is not in the tx_graphand not in the dedup set. Isn't not having the Txid in the graph enough to panic due to it being invalid?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Sorry this was never meant to be in the public API. The idea is that we should only add txids into the BroadcastQueue which are also in TxGraph. If that is not the case, it is definitely an internal BDK error.

Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
@evanlinjin

evanlinjin commented Jun 15, 2025

Copy link
Copy Markdown
MemberAuthor

Can we enforce or support BroadcastQueue as the only way to broadcast transactions in bdk_wallet? A user bypassing this mechanism should be considered? Are there reasons to not doing it? Why would you keep broadcasting tx outside of the queue when you have unbroadcasted tx in the queue?

@nymius I've rethought about this problem. I think instead of a BroadcastQueue, it should really be an IntentTracker (refer to my comment here). Broadcast-ability can be evaluated on a trasaction-by-transaction basis. I do not think it is viable to enforce BroadcastQueue as the only way to broadcast transactions are BDK is not responsible for broadcasting directly to the mempool.

I like that the queue purges itself automatically on syncs.

@thunderbiscuit I agree that it is nice to reason with. However, I think it will introduce some footguns. Let me provide an example:

  • Transaction A (an intended payment) is broadcasted.
  • Transaction A gets evicted from the mempool so it disappears from the transaction list.
  • The user realizes this and creates a second transaction (B) to atone for the disappearance of transaction A. However, the coin selection puts A and B on non-conflicting subgraphs.

Now A and B can exist in the same history, and thus we have the potential birth of a double-payment situation.

My proposal right now, as mentioned above, is to have an IntentTracker which the user needs to explicitly forget or replace a "diverged" transaction.

The wallet will keep track of two consistent views of history:

  1. The canonical network view. This is what BDK assumes to be what the network sees. Currently wallet.transactions returns this.
  2. The canonical intent view. This is what the user intends to happen.

If these two views are the same, no action is required. If these two views diverge, the caller should be able to easily respond to it explicitly.

  • The tx merely needs a broadcast.
  • The tx is low fee so needs RBF/CPFP.
  • An input is no longer available. RBF?
  • Explicitly forgetting (this is safe if a conflict is x number of confirmations deep).

@evanlinjin

evanlinjin commented Jun 17, 2025

Copy link
Copy Markdown
MemberAuthor

When doing coin selection, we should use the "intent view" to obtain the UTXO set. This is to avoid accidentally double-spending intended-to-be-canonical transactions.

However, some intended-to-be-canonical transactions could not be canonical now (due to confirmed conflicts), or conflicts with mempool transactions (RBF, which may not go through in time).

So there should be some sort of filtering based on transactions in the IntentTracker:

  • Don't spend from transactions with confirmed conflicts.
  • Try to avoid spending from transactions with unconfirmed conflicts.
  • Try to avoid spending from unbroadcasted transactions.
  • Try to avoid spending from evicted transactions.

Of course, there are other filters that BDK does not do, but should really do (out of scope of this PR, but probably part of the same interface/structure):

  • Try to avoid spending from untrusted unconfirmed outputs (as they can be cancelled/replaced by another party).
  • Try to avoid spending from unconfirmed transactions in general.

@tnull

tnull commented Jun 18, 2025

Copy link
Copy Markdown
Contributor

Do we know how Core handles these things?

@tnull do you mean how does the Core wallet handle un-broadcasted Tx and building new Tx on those un-broadcasted Tx outputs? As far as I know there are no features in the Core wallet for this beyond you the user holding on to your signed and un-broadcast Tx and manually building on those Tx outputs with the commands:

1. [createrawtransaction](https://bitcoincore.org/en/doc/29.0.0/rpc/rawtransactions/createrawtransaction/)
2. [signrawtransactionwithwallet ](https://bitcoincore.org/en/doc/29.0.0/rpc/wallet/signrawtransactionwithwallet/)
3. when you're ready to broadcast any of these Tx [sendrawtransaction](https://bitcoincore.org/en/doc/29.0.0/rpc/rawtransactions/sendrawtransaction/)

Mh, right, regarding the UTXO locking usecase, it does feature a rather simple interface through lockunspent / listlockunspent though. As said on #166, that (mod maybe an auto-unlock feature) would likely be all we'd really need on our end for now, I think.

@nymius

nymius commented Jun 19, 2025

Copy link
Copy Markdown
Contributor

Thanks for modeling this, from my perspective, it resembles to React DOM and virtual DOM, and its reconciliation model.

The user realizes this and creates a second transaction (B) to atone for the disappearance of transaction A. However, the coin selection puts A and B on non-conflicting subgraphs.

A quick check: when you say non-conflicting subgraphs, it is implied B is not spending any inputs from A, but is spending to the same outputs, right?

My proposal right now, as mentioned above, is to have an IntentTracker which the user needs to explicitly forget or replace a "diverged" transaction.

Do you have in mind some diff method between this IntentTracker and the canonical to find these divergences?
For the updates, all these actions you mentioned (re-broadcast, rbf, cpfp, forget) will be implemented as IntentTrackers methods?


The canonical network view. This is what BDK assumes to be what the network sees. Currently wallet.transactions returns this.

I'm confused here, Wallet.transactions docs say the following:

/// Iterate over relevant and canonical transactions in the wallet.
///
/// A transaction is relevant when it spends from or spends to at least one tracked output. A
/// transaction is canonical when it is confirmed in the best chain, or does not conflict
/// with any transaction confirmed in the best chain.

My guess is relevant transactions should be left out of the equation here.

@notmandatorynotmandatory modified the milestone: Wallet 3.0.0Jun 25, 2025
@evanlinjinevanlinjin changed the title Introduce BroadcastQueueIntroduce IntentTrackerJun 28, 2025
This was referenced Jul 4, 2025
evanlinjin added a commit to bitcoindevkit/bdk that referenced this pull request Jul 26, 2025
51ee99a docs(bitcoind_rpc): fixed typo in docs (Wei Chen)
73ab1eb chore(bitcoind_rpc): Make clippy happy (志宇)
7e894f4 feat(bitcoind_rpc)!: Use `getrawmempool` without verbose (志宇)
05464ec fix(bitcoind_rpc)!: Simplify emitter (志宇)
67dfb0b test(bitcoind_rpc): Detect new mempool txs (志宇)
Pull request description:
### Description
There is a bug in `bdk_bitcoind_rpc` where some new mempool transactions will not be emitted at all.
This problem exists because the avoid-re-emission logic depends on rounded-to-nearest-second timestamps.
The fix is to just emit all mempool transactions but wrap them in `Arc`s so that emission becomes cheap.
**Background:** I tried using `bdk_bitcoind_rpc` as the chain-source to write an example to showcase the [`IntentTracker`](bitcoindevkit/bdk_wallet#257). However, `bdk_bitcoind_rpc` failed to emit some mempool transactions.
### Notes to the reviewers
The test added in c22c68f fails without these fixes.
Some tests are removed as they are no longer relevant.
### Changelog notice
```md
Fixed:
- Some mempool transactions not being emitted at all. The fix is to replace the avoid-re-emission-logic with one which emits all mempool transactions.
```
### Checklists
#### All Submissions:
* [x] I've signed all my commits
* [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md)
* [x] I ran `cargo +nightly fmt` and `cargo clippy` before committing
#### Bugfixes:
* [x] This pull request breaks the existing API
* [x] I've added tests to reproduce the issue which are now passing
~* [ ] I'm linking the issue being fixed by this PR~
ACKs for top commit:
nymius:
cACK 51ee99a
LagginTimes:
Re-ACK 51ee99a
Tree-SHA512: 04e180e1d28c3f4c581a61ccac95e8e7e6927123d272ed07eae0ae51bf70799df44298b47ba0e49a309fd76366875e8d18d73478252931713137844857b8ed5a
@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch from e3ec37b to 2f9249bCompareAugust 3, 2025 13:05
kwsantiago pushed a commit to privkeyio/bdk that referenced this pull request Aug 5, 2025
51ee99a docs(bitcoind_rpc): fixed typo in docs (Wei Chen)
73ab1eb chore(bitcoind_rpc): Make clippy happy (志宇)
7e894f4 feat(bitcoind_rpc)!: Use `getrawmempool` without verbose (志宇)
05464ec fix(bitcoind_rpc)!: Simplify emitter (志宇)
67dfb0b test(bitcoind_rpc): Detect new mempool txs (志宇)
Pull request description:
### Description
There is a bug in `bdk_bitcoind_rpc` where some new mempool transactions will not be emitted at all.
This problem exists because the avoid-re-emission logic depends on rounded-to-nearest-second timestamps.
The fix is to just emit all mempool transactions but wrap them in `Arc`s so that emission becomes cheap.
**Background:** I tried using `bdk_bitcoind_rpc` as the chain-source to write an example to showcase the [`IntentTracker`](bitcoindevkit/bdk_wallet#257). However, `bdk_bitcoind_rpc` failed to emit some mempool transactions.
### Notes to the reviewers
The test added in c22c68f fails without these fixes.
Some tests are removed as they are no longer relevant.
### Changelog notice
```md
Fixed:
- Some mempool transactions not being emitted at all. The fix is to replace the avoid-re-emission-logic with one which emits all mempool transactions.
```
### Checklists
#### All Submissions:
* [x] I've signed all my commits
* [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md)
* [x] I ran `cargo +nightly fmt` and `cargo clippy` before committing
#### Bugfixes:
* [x] This pull request breaks the existing API
* [x] I've added tests to reproduce the issue which are now passing
~* [ ] I'm linking the issue being fixed by this PR~
ACKs for top commit:
nymius:
cACK 51ee99a
LagginTimes:
Re-ACK 51ee99a
Tree-SHA512: 04e180e1d28c3f4c581a61ccac95e8e7e6927123d272ed07eae0ae51bf70799df44298b47ba0e49a309fd76366875e8d18d73478252931713137844857b8ed5a
@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch from 2f9249b to 5816070CompareAugust 7, 2025 08:39
@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch from 5816070 to 5d70885CompareAugust 29, 2025 01:30
@ovitrif

ovitrif commented Sep 8, 2025

Copy link
Copy Markdown

Hi guys, Bitkit team dev here, needing this to unlock:

Why?

EDIT: nvm, #6 is now fixed by #310. Thank you for your attention and collaboration 🙏🏻.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new featureNew feature or request

Projects

Archived in project

8 participants

@evanlinjin@coveralls@thunderbiscuit@notmandatory@tnull@nymius@ovitrif@ValuedMammal
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Introduce `IntentTracker` by evanlinjin · Pull Request #257 · bitcoindevkit/bdk_wallet · GitHub
Skip to content

Introduce IntentTracker - #257

Closed
evanlinjin wants to merge 7 commits into
bitcoindevkit:masterfrom
evanlinjin:feature/broadcast-queue
Closed

Introduce IntentTracker#257
evanlinjin wants to merge 7 commits into
bitcoindevkit:masterfrom
evanlinjin:feature/broadcast-queue

Conversation

@evanlinjin

@evanlinjinevanlinjin commented Jun 6, 2025

Copy link
Copy Markdown
Member

Fixes#166
Fixes#40
Fixed#295
Replaces #220

Description

Allows callers to spend from unbroadcasted transactions.

Notes to the reviewers

I think I may have done some overthinking for the BroadcastQueue implementation. This is the current implementation:

  • Wallet::add_tx_to_broadcast_queue will also remove conflicts (of the tx being inserted) from the broadcast queue.
  • Wallet::remove_tx_from_broadcast_queue will also remove descendants of the tx being removed.

However, I’m not convinced this feature is necessary, and it could lead to inconsistent behavior if callers sometimes use the BroadcastQueue, bypass it to broadcast transactions directly, or if multiple instances of the same wallet broadcast concurrently. In such cases—when intermediate transactions are missing from the queue—the logic described above will fail.

There is an argument for RBF, however, why would you need to RBF unbroadcasted transactions? It's better to empty the queue and start again.

Changelog notice

Checklists

To Get Out of Draft Status:

  • Have a section in the struct-level (Wallet) docs that explains the broadcast queue.
  • Better docs for each new method added.
  • Example: Wallet with single UTXO. Create x number of transactions sequentially. Broadcast all in one go. Sync.
  • Test persistence (sqlite).
  • More tests.

To Get This Merged:

All Submissions:

  • I've signed all my commits
  • I followed the contribution guidelines
  • I ran cargo +nightly fmt and cargo clippy before committing

New Features:

  • I've added tests for the new feature
  • I've added docs for the new feature

Bugfixes:

  • This pull request breaks the existing API
  • I've added tests to reproduce the issue which are now passing
  • I'm linking the issue being fixed by this PR

@coveralls

coveralls commented Jun 6, 2025

Copy link
Copy Markdown

Pull Request Test Coverage Report for Build 15943002368

Warning: This coverage report may be inaccurate.

This pull request's base commit is no longer the HEAD commit of its target branch. This means it includes changes from outside the original pull request, including, potentially, unrelated coverage changes.

Details

  • 213 of 632(33.7%) changed or added relevant lines in 5 files are covered.
  • 14 unchanged lines in 5 files lost coverage.
  • Overall coverage decreased (-4.9%) to 80.602%

Changes Missing CoverageCovered LinesChanged/Added Lines%
wallet/src/wallet/tx_builder.rs21020.0%
wallet/src/wallet/changeset.rs254160.98%
wallet/src/wallet/mod.rs13522260.81%
wallet/src/wallet/intent_tracker.rs4935713.73%
Files with Coverage ReductionNew Missed Lines%
wallet/src/descriptor/dsl.rs195.34%
wallet/src/wallet/changeset.rs279.44%
wallet/src/descriptor/policy.rs379.07%
wallet/src/descriptor/template.rs498.04%
wallet/src/wallet/mod.rs478.06%
TotalsCoverage Status
Change from base Build 15476130196:-4.9%
Covered Lines:6644
Relevant Lines:8243

💛 - Coveralls

@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch 2 times, most recently from 808bbdf to 75bc892CompareJune 6, 2025 10:27
@evanlinjinevanlinjin self-assigned this Jun 7, 2025
@notmandatorynotmandatory moved this to In Progress in BDK WalletJun 7, 2025
@notmandatorynotmandatory added the new feature New feature or request label Jun 7, 2025
Comment threadwallet/src/types.rs
/// The position of the output in the blockchain.
pub chain_position: ChainPosition<ConfirmationBlockTime>,
/// Whether this output exists in a transaction that is yet to be broadcasted.
pub needs_broadcast: bool,

@nymiusnymiusJun 8, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe an enum state field with something like: UNSPENT, ON_QUEUE, SPENT, BROADCASTED will avoid keep adding new boolean fields here, and provide a better path for update on future occasions, taking advantage of non exhaustive patters. is_spent could be marked for deprecation and be used along the new field in the meantime.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I like this idea if done as a non-exhaustive enum to help reduce future API breaking changes. If we include a "LOCKED" variant could this also support #259?

@nymiusnymius left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

However, I’m not convinced this feature is necessary, and it could lead to inconsistent behavior if callers sometimes use the BroadcastQueue, bypass it to broadcast transactions directly, or if multiple instances of the same wallet broadcast concurrently. In such cases—when intermediate transactions are missing from the queue—the logic described above will fail.

Can we enforce or support BroadcastQueue as the only way to broadcast transactions in bdk_wallet?
A user bypassing this mechanism should be considered? Are there reasons to not doing it?
Why would you keep broadcasting tx outside of the queue when you have an unbroadcasted tx in the queue?

I like the approach, and think is easy to reason about. Maybe we could leave the door open to implement other broadcast policies.
IMHO, the BroadcastQueue "profile" should ensure internal consistency, so I don't think is over engineered.

Why would you need to RBF unbroadcasted transactions?

Not a use case that I've needed, but maybe share multiple conflicting transactions offline looking for fee optimization in different scenarios.

@nymiusnymius mentioned this pull request Jun 8, 2025
7 tasks
@thunderbiscuit

thunderbiscuit commented Jun 9, 2025

Copy link
Copy Markdown
Member

Concept ACK. I like the idea of the queue.

I took a look at the diff and here are some thoughts/questions, pardon me if some of them would have been answered by doing a code deep dive, I just know you wanted early feedback so decided to get moving on it sooner than later.

  • Simple is good in my mind. If one of the requirements of the queue is that it's always internally valid and could in theory be broadcast all at once in one go, that's an easier mental model than allowing conflicts in the queue.
  • If the queue can actually have conflicts, it's less of a queue and more of a "bag" of transactions. Again less easy to reason about, and now the naming is misleading from the point of view of the users (I mean it's not that bad, I just mean it's not as neat/pure)
  • I like that the queue purges itself automatically on syncs.
  • I am potentially drawn to the idea from @nymius that all transactions could need to go in the queue first to then be broadcast. I wonder if that's an elegant way to force clean setups and handle the fact that a ton of wallets probably don't do costly sync every time they build transactions. That way the queue would always be aware of what has been broadcast. Does that complicate things too much? It would just be important that the library not have any footguns that would for example have you forget about a tx in the queue, persist it, then weeks later you just do Wallet::broadcast_queue and bam you just sent more than you wanted.

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for tackling this, took a first look.

I think I may have done some overthinking for the BroadcastQueue implementation. This is the current implementation:

Do we know how Core handles these things?

Also, when do we expect this queue to be processed? Would this happen manually or automatically in intervals?

Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
queue: VecDeque<Txid>,

/// Enforces that we do not have duplicates in `queue`.
dedup: HashSet<Txid>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I wonder if it's worth having this separate set? How many unbroadcasted transactions are we expecting at any given time? Maybe it would just be quicker to simply iterate over the queue itself, also saving the heap allocations/memory footprint?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

That is a good point. Maybe premature optimization here.

Comment threadwallet/src/types.rs
/// The position of the output in the blockchain.
pub chain_position: ChainPosition<ConfirmationBlockTime>,
/// Whether this output exists in a transaction that is yet to be broadcasted.
pub needs_broadcast: bool,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When do we expect this to be set/unset exactly? I guess it can only be unset once the transaction in question reaches threshold confirmations?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

That is a good point, and it shows the limitations of the BroadcastQueue concept. In fact, I did some further thinking on this and the BroadcastQueue should really be an IntentTracker and should track txs even if they are "network canonical".

There should be a method such as .tracked_txs_which_are_not_network_canonical (better name needed) so that the caller can decide to either replace the tx, or explicitly forget about it. There are caveats to doing both since we don't want to create a sub-graph where intended payments are duplicated - BDK should handle these situations properly, or provide the required information so that the caller can make a safe decision.

@notmandatory

notmandatory commented Jun 12, 2025

Copy link
Copy Markdown
Member

Do we know how Core handles these things?

@tnull do you mean how does the Core wallet handle un-broadcasted Tx and building new Tx on those un-broadcasted Tx outputs? As far as I know there are no features in the Core wallet for this beyond you the user holding on to your signed and un-broadcast Tx and manually building on those Tx outputs with the commands:

  1. createrawtransaction
  2. signrawtransactionwithwallet
  3. when you're ready to broadcast any of these Tx sendrawtransaction

@notmandatorynotmandatory left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Overall looks like a powerful new feature, I only have minor comments. Once you feel the API is ready I'd like to have a live chat to review it with L2 users like @tnull and @stevenroose to validate it meets their use cases.

Comment threadwallet/src/types.rs
/// The position of the output in the blockchain.
pub chain_position: ChainPosition<ConfirmationBlockTime>,
/// Whether this output exists in a transaction that is yet to be broadcasted.
pub needs_broadcast: bool,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I like this idea if done as a non-exhaustive enum to help reduce future API breaking changes. If we include a "LOCKED" variant could this also support #259?

Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
let tx = match tx_graph.get_tx(txid) {
Some(tx) => tx,
None => {
debug_assert!(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Would it be better to throw and Err here instead of the panic? It seems possible a user could mistakenly try to queue a Txid not in the tx_graph. Or is this ment to warn app devs that they should never let this happen?

I also don't understand why you only panic if the txid is not in the tx_graphand not in the dedup set. Isn't not having the Txid in the graph enough to panic due to it being invalid?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Sorry this was never meant to be in the public API. The idea is that we should only add txids into the BroadcastQueue which are also in TxGraph. If that is not the case, it is definitely an internal BDK error.

Comment threadwallet/src/wallet/broadcast_queue.rs Outdated
@evanlinjin

evanlinjin commented Jun 15, 2025

Copy link
Copy Markdown
MemberAuthor

Can we enforce or support BroadcastQueue as the only way to broadcast transactions in bdk_wallet? A user bypassing this mechanism should be considered? Are there reasons to not doing it? Why would you keep broadcasting tx outside of the queue when you have unbroadcasted tx in the queue?

@nymius I've rethought about this problem. I think instead of a BroadcastQueue, it should really be an IntentTracker (refer to my comment here). Broadcast-ability can be evaluated on a trasaction-by-transaction basis. I do not think it is viable to enforce BroadcastQueue as the only way to broadcast transactions are BDK is not responsible for broadcasting directly to the mempool.

I like that the queue purges itself automatically on syncs.

@thunderbiscuit I agree that it is nice to reason with. However, I think it will introduce some footguns. Let me provide an example:

  • Transaction A (an intended payment) is broadcasted.
  • Transaction A gets evicted from the mempool so it disappears from the transaction list.
  • The user realizes this and creates a second transaction (B) to atone for the disappearance of transaction A. However, the coin selection puts A and B on non-conflicting subgraphs.

Now A and B can exist in the same history, and thus we have the potential birth of a double-payment situation.

My proposal right now, as mentioned above, is to have an IntentTracker which the user needs to explicitly forget or replace a "diverged" transaction.

The wallet will keep track of two consistent views of history:

  1. The canonical network view. This is what BDK assumes to be what the network sees. Currently wallet.transactions returns this.
  2. The canonical intent view. This is what the user intends to happen.

If these two views are the same, no action is required. If these two views diverge, the caller should be able to easily respond to it explicitly.

  • The tx merely needs a broadcast.
  • The tx is low fee so needs RBF/CPFP.
  • An input is no longer available. RBF?
  • Explicitly forgetting (this is safe if a conflict is x number of confirmations deep).

@evanlinjin

evanlinjin commented Jun 17, 2025

Copy link
Copy Markdown
MemberAuthor

When doing coin selection, we should use the "intent view" to obtain the UTXO set. This is to avoid accidentally double-spending intended-to-be-canonical transactions.

However, some intended-to-be-canonical transactions could not be canonical now (due to confirmed conflicts), or conflicts with mempool transactions (RBF, which may not go through in time).

So there should be some sort of filtering based on transactions in the IntentTracker:

  • Don't spend from transactions with confirmed conflicts.
  • Try to avoid spending from transactions with unconfirmed conflicts.
  • Try to avoid spending from unbroadcasted transactions.
  • Try to avoid spending from evicted transactions.

Of course, there are other filters that BDK does not do, but should really do (out of scope of this PR, but probably part of the same interface/structure):

  • Try to avoid spending from untrusted unconfirmed outputs (as they can be cancelled/replaced by another party).
  • Try to avoid spending from unconfirmed transactions in general.

@tnull

tnull commented Jun 18, 2025

Copy link
Copy Markdown
Contributor

Do we know how Core handles these things?

@tnull do you mean how does the Core wallet handle un-broadcasted Tx and building new Tx on those un-broadcasted Tx outputs? As far as I know there are no features in the Core wallet for this beyond you the user holding on to your signed and un-broadcast Tx and manually building on those Tx outputs with the commands:

1. [createrawtransaction](https://bitcoincore.org/en/doc/29.0.0/rpc/rawtransactions/createrawtransaction/)
2. [signrawtransactionwithwallet ](https://bitcoincore.org/en/doc/29.0.0/rpc/wallet/signrawtransactionwithwallet/)
3. when you're ready to broadcast any of these Tx [sendrawtransaction](https://bitcoincore.org/en/doc/29.0.0/rpc/rawtransactions/sendrawtransaction/)

Mh, right, regarding the UTXO locking usecase, it does feature a rather simple interface through lockunspent / listlockunspent though. As said on #166, that (mod maybe an auto-unlock feature) would likely be all we'd really need on our end for now, I think.

@nymius

nymius commented Jun 19, 2025

Copy link
Copy Markdown
Contributor

Thanks for modeling this, from my perspective, it resembles to React DOM and virtual DOM, and its reconciliation model.

The user realizes this and creates a second transaction (B) to atone for the disappearance of transaction A. However, the coin selection puts A and B on non-conflicting subgraphs.

A quick check: when you say non-conflicting subgraphs, it is implied B is not spending any inputs from A, but is spending to the same outputs, right?

My proposal right now, as mentioned above, is to have an IntentTracker which the user needs to explicitly forget or replace a "diverged" transaction.

Do you have in mind some diff method between this IntentTracker and the canonical to find these divergences?
For the updates, all these actions you mentioned (re-broadcast, rbf, cpfp, forget) will be implemented as IntentTrackers methods?


The canonical network view. This is what BDK assumes to be what the network sees. Currently wallet.transactions returns this.

I'm confused here, Wallet.transactions docs say the following:

/// Iterate over relevant and canonical transactions in the wallet.
///
/// A transaction is relevant when it spends from or spends to at least one tracked output. A
/// transaction is canonical when it is confirmed in the best chain, or does not conflict
/// with any transaction confirmed in the best chain.

My guess is relevant transactions should be left out of the equation here.

@notmandatorynotmandatory modified the milestone: Wallet 3.0.0Jun 25, 2025
@evanlinjinevanlinjin changed the title Introduce BroadcastQueueIntroduce IntentTrackerJun 28, 2025
This was referenced Jul 4, 2025
evanlinjin added a commit to bitcoindevkit/bdk that referenced this pull request Jul 26, 2025
51ee99a docs(bitcoind_rpc): fixed typo in docs (Wei Chen)
73ab1eb chore(bitcoind_rpc): Make clippy happy (志宇)
7e894f4 feat(bitcoind_rpc)!: Use `getrawmempool` without verbose (志宇)
05464ec fix(bitcoind_rpc)!: Simplify emitter (志宇)
67dfb0b test(bitcoind_rpc): Detect new mempool txs (志宇)
Pull request description:
### Description
There is a bug in `bdk_bitcoind_rpc` where some new mempool transactions will not be emitted at all.
This problem exists because the avoid-re-emission logic depends on rounded-to-nearest-second timestamps.
The fix is to just emit all mempool transactions but wrap them in `Arc`s so that emission becomes cheap.
**Background:** I tried using `bdk_bitcoind_rpc` as the chain-source to write an example to showcase the [`IntentTracker`](bitcoindevkit/bdk_wallet#257). However, `bdk_bitcoind_rpc` failed to emit some mempool transactions.
### Notes to the reviewers
The test added in c22c68f fails without these fixes.
Some tests are removed as they are no longer relevant.
### Changelog notice
```md
Fixed:
- Some mempool transactions not being emitted at all. The fix is to replace the avoid-re-emission-logic with one which emits all mempool transactions.
```
### Checklists
#### All Submissions:
* [x] I've signed all my commits
* [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md)
* [x] I ran `cargo +nightly fmt` and `cargo clippy` before committing
#### Bugfixes:
* [x] This pull request breaks the existing API
* [x] I've added tests to reproduce the issue which are now passing
~* [ ] I'm linking the issue being fixed by this PR~
ACKs for top commit:
nymius:
cACK 51ee99a
LagginTimes:
Re-ACK 51ee99a
Tree-SHA512: 04e180e1d28c3f4c581a61ccac95e8e7e6927123d272ed07eae0ae51bf70799df44298b47ba0e49a309fd76366875e8d18d73478252931713137844857b8ed5a
@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch from e3ec37b to 2f9249bCompareAugust 3, 2025 13:05
kwsantiago pushed a commit to privkeyio/bdk that referenced this pull request Aug 5, 2025
51ee99a docs(bitcoind_rpc): fixed typo in docs (Wei Chen)
73ab1eb chore(bitcoind_rpc): Make clippy happy (志宇)
7e894f4 feat(bitcoind_rpc)!: Use `getrawmempool` without verbose (志宇)
05464ec fix(bitcoind_rpc)!: Simplify emitter (志宇)
67dfb0b test(bitcoind_rpc): Detect new mempool txs (志宇)
Pull request description:
### Description
There is a bug in `bdk_bitcoind_rpc` where some new mempool transactions will not be emitted at all.
This problem exists because the avoid-re-emission logic depends on rounded-to-nearest-second timestamps.
The fix is to just emit all mempool transactions but wrap them in `Arc`s so that emission becomes cheap.
**Background:** I tried using `bdk_bitcoind_rpc` as the chain-source to write an example to showcase the [`IntentTracker`](bitcoindevkit/bdk_wallet#257). However, `bdk_bitcoind_rpc` failed to emit some mempool transactions.
### Notes to the reviewers
The test added in c22c68f fails without these fixes.
Some tests are removed as they are no longer relevant.
### Changelog notice
```md
Fixed:
- Some mempool transactions not being emitted at all. The fix is to replace the avoid-re-emission-logic with one which emits all mempool transactions.
```
### Checklists
#### All Submissions:
* [x] I've signed all my commits
* [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md)
* [x] I ran `cargo +nightly fmt` and `cargo clippy` before committing
#### Bugfixes:
* [x] This pull request breaks the existing API
* [x] I've added tests to reproduce the issue which are now passing
~* [ ] I'm linking the issue being fixed by this PR~
ACKs for top commit:
nymius:
cACK 51ee99a
LagginTimes:
Re-ACK 51ee99a
Tree-SHA512: 04e180e1d28c3f4c581a61ccac95e8e7e6927123d272ed07eae0ae51bf70799df44298b47ba0e49a309fd76366875e8d18d73478252931713137844857b8ed5a
@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch from 2f9249b to 5816070CompareAugust 7, 2025 08:39
@evanlinjin
evanlinjinforce-pushed the feature/broadcast-queue branch from 5816070 to 5d70885CompareAugust 29, 2025 01:30
@ovitrif

ovitrif commented Sep 8, 2025

Copy link
Copy Markdown

Hi guys, Bitkit team dev here, needing this to unlock:

Why?

EDIT: nvm, #6 is now fixed by #310. Thank you for your attention and collaboration 🙏🏻.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new featureNew feature or request

Projects

Archived in project

8 participants

@evanlinjin@coveralls@thunderbiscuit@notmandatory@tnull@nymius@ovitrif@ValuedMammal