Skip to content

fix(wallet): exclude unconfirmed v3 outputs from non-v3 coin selection#442

Open
EliteCoder18 wants to merge 2 commits intobitcoindevkit:masterfrom
EliteCoder18:fix/truc-utxo-filter
Open

fix(wallet): exclude unconfirmed v3 outputs from non-v3 coin selection#442
EliteCoder18 wants to merge 2 commits intobitcoindevkit:masterfrom
EliteCoder18:fix/truc-utxo-filter

Conversation

@EliteCoder18
Copy link
Copy Markdown

Description

BIP-431 (TRUC) requires that any transaction spending an unconfirmed v3 output must itself be v3. BDK's coin selection had no awareness of this and would freely include unconfirmed v3 parent outputs when building standard non-v3 transactions, causing bitcoind to reject the broadcast.

This PR threads tx_version into filter_utxos to exclude such UTXOs when the target transaction is not v3. Confirmed outputs are unaffected.

Fixes #419.

Notes to the reviewers

The fix is intentionally minimal only the cross-version spending restriction from BIP-431 is addressed here. The broader TRUC topological constraints (cluster size, child vbyte limit) are out of scope for this PR and can be tracked separately.

Manually selected UTXOs via TxBuilder::add_utxo bypass filter_utxos entirely and are not covered by this fix. Callers are responsible for not adding unconfirmed v3 outputs to non-v3 transactions manually.

Changelog notice

  • Fixed: filter_utxos now excludes unconfirmed UTXOs from v3 parent transactions when constructing non-v3 transactions, preventing TRUC policy violations at broadcast.

Checklists

All Submissions:

  • I've signed all my commits
  • I followed the contribution guidelines
  • I ran just p before pushing

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

@ValuedMammal ValuedMammal added the bug Something isn't working label Apr 14, 2026
@ValuedMammal ValuedMammal added this to the Wallet 3.1.0 milestone Apr 14, 2026
@ValuedMammal ValuedMammal moved this to In Progress in BDK Wallet Apr 14, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 14, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.24%. Comparing base (db1eab4) to head (230ebcb).

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #442      +/-   ##
==========================================
+ Coverage   80.21%   80.24%   +0.02%     
==========================================
  Files          24       24              
  Lines        5348     5355       +7     
  Branches      242      243       +1     
==========================================
+ Hits         4290     4297       +7     
  Misses        980      980              
  Partials       78       78              
Flag Coverage Δ
rust 80.24% <100.00%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ValuedMammal ValuedMammal moved this from In Progress to Needs Review in BDK Wallet Apr 14, 2026
ValuedMammal added a commit that referenced this pull request Apr 23, 2026
…_unchecked

be710a8 chore(tests): replace deprecated FeeRate::from_sat_per_vb_unchecked (none34829)

Pull request description:

  ### Description

  `bitcoin-units 0.1.3` deprecated `FeeRate::from_sat_per_vb_unchecked` in favor of `FeeRate::from_sat_per_vb_u32`. Cargo resolves any 0.1.x automatically, so fresh CI runs started failing on master (and all open PRs) once 0.1.3 was published.

  This patch swaps every call site over to the new name. The two functions compute the same value for any input that fits in `u32`; every existing call site passes a small literal (1, 3, 5, 10, 25, 50, 255, 454, 1000, 10_000), so no behavioural change.

  All usages are in test/fixture code (`src/wallet/coin_selection.rs` test module, `tests/wallet.rs`, `tests/build_fee_bump.rs`) — no production code path was using the deprecated function.

  ### Notes to the reviewers

  - Pure mechanical rename; no logic or type changes.
  - Unblocks CI on master and every open PR (e.g. #449, #442, #445, #448).

  ### Changelog notice

  N/A — internal test-only change.

  ### 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 `just p` before pushing

ACKs for top commit:
  ValuedMammal:
    ACK be710a8

Tree-SHA512: b2354fcf6e39228bc5796f92af4d3692fbdb750267c2c5a7d814fc5c84f4af64b746562fda7e01dac282ba04acb17ff7bf5c7ef0155a3d6f302974003baf0629
Copy link
Copy Markdown
Contributor

@oleonardolima oleonardolima left a comment

Choose a reason for hiding this comment

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

Thanks for working on this one!

I'm still wondering if it's the best way to address the issue. I'll give it a try on some other alternatives.

Comment thread src/wallet/mod.rs Outdated
&self,
params: &TxParams,
current_height: u32,
tx_version: transaction::Version,
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.

You don't need to add a new argument, it's already available in TxParams.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done.... removed the extra argument and reading from TxParams.version directly now.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yes but we already validated the transaction version around line 1294, so not re-initializing it with unwrap_or(Version::TWO) would also make sense?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good point, thanks!

You're right, we already validate and resolve the transaction version earlier in create_tx. Re-doing unwrap_or(Version::TWO) inside filter_utxos is redundant.

I'll update it to pass the already-resolved version directly into filter_utxos from the call site instead of re-deriving it inside the function.

Copy link
Copy Markdown

@yan-pi yan-pi left a comment

Choose a reason for hiding this comment

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

First I reproduced #419 against pre-fix bdk-cli and bitcoind rejects on broadcast as expected.

Validated this branch with a local path dep:
create_tx returns InsufficientFunds, available == confirmed matched exactly.

Covered direction works as advertised.

That said, Fixes #419 closes the issue on merge, but some gaps remain:

  • v3 -> non-v3 unconfirmed (other half of BIP-431 Rule 2).
    Reproducible on this branch.
  • add_utxo / add_foreign_utxo bypass: PR body mentions, rustdoc doesn't.
  • vB rules (BIP-431 Rules 3/4/5): out of scope in this PR.
    But oleonardolima asked for this in the issue.

I think you should either expand scope, or change body to "addresses part of #419" and link follow-ups before merge, dunno about how this last one is handled in BDK org.

Comment thread src/wallet/mod.rs Outdated
// NOTE: manual selection overrides unspendable
let mut required: Vec<WeightedUtxo> = params.utxos.clone();
let optional = self.filter_utxos(&params, current_height.to_consensus_u32());
let optional = self.filter_utxos(&params, current_height.to_consensus_u32(), version);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The bypass for add_utxo / add_foreign_utxo is mentioned in the PR body, but not in the rustdoc of those methods.

IMO worth a note.
An integrator using manual selection might assume "fixes #419" covers them and still hit TRUC-violation.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for calling this out.... That's a great point. I’ve now added explicit rustdoc notes on both TxBuilder::add_utxo and TxBuilder::add_foreign_utxo clarifying that manually selected UTXOs bypass optional-UTXO filtering (including TRUC version-compatibility checks), so callers must validate unconfirmed manual inputs against the target tx version themselves.

This should make the behavior clear for integrators and avoid a false assumption that the #419 fix also covers manual selection paths.

Comment thread src/wallet/mod.rs
|| tx_graph
.get_tx(local_output.outpoint.txid)
.is_none_or(|tx| tx.version != transaction::Version(3))
})
Copy link
Copy Markdown

@yan-pi yan-pi Apr 29, 2026

Choose a reason for hiding this comment

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

BIP-431 Rule 2 has two clauses, this predicate only covers one:

Any TRUC transaction's unconfirmed ancestors must all be TRUC. Any descendant of an unconfirmed TRUC transaction must also be TRUC.

When target is v3, the filter is off: coin selection can pick non-v3 unconfirmed UTXOs and bitcoind rejects on broadcast.

Same shape as #419, other direction.

Reproduced on this branch:

1.5 BTC confirmed + 0.3 BTC v2 unconfirmed, target v3 - 1.6 BTC -> PSBT includes the v2 unconfirmed.

Attempting broadcast (expected: TRUC-violation)...
  bitcoind rejected: JSON-RPC error: RPC error response: RpcError { code: -26, message: "TRUC-violation, version=
3 tx 6d60a9caac7631812c5ed083c8c583d3cd9382485640aa7a543d3c5d19a00419 (wtxid=726b4a450e32084d5237c8a098f9e26c399b
e7f27ae3af7d328bb000ddb4aa19) cannot spend from non-version=3 tx 8ff29f49e540dbd32c9d1207628861504e0f981a1f5a2025
b6550bd380118724 (wtxid=38b52d8ad666a8296a4c4383e4572dc8b45cc796203e85d39033b772cabc1242)", data: None }

Not sure if you'd want to fix here or create a follow-up issue/pr?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The bidirectional filter is included in the latest revision both directions are covered by (tx.version.0 == 3) == target_is_truc. Could you re-test against the current HEAD? Happy to look into it further if the violation still reproduces.

When a v3 (TRUC) transaction produced change back into the wallet, that
unconfirmed output entered coin selection like any other UTXO. Building
a subsequent non-v3 transaction that spent it caused bitcoind to reject
the broadcast with "TRUC-violation, non-version=3 tx cannot spend from
version=3 tx" (BIP-431).

Thread `tx_version` into `filter_utxos` so that unconfirmed UTXOs whose
parent is version 3 are excluded when building a non-v3 transaction.
Confirmed outputs are unaffected since TRUC rules only apply while the
parent remains unconfirmed.

Fixes bitcoindevkit#419.
Add two tests covering BIP-431 (TRUC) coin selection behavior.

`test_create_tx_non_v3_excludes_unconfirmed_v3_utxos` verifies that a
non-v3 transaction cannot coin-select an unconfirmed v3 parent output,
while a v3 transaction targeting the same output succeeds.

`test_create_tx_non_v3_allows_unconfirmed_non_v3_utxos` verifies that
unconfirmed outputs from non-v3 parents remain eligible for standard
transactions, preserving existing behavior.
@EliteCoder18 EliteCoder18 force-pushed the fix/truc-utxo-filter branch from cd4dd53 to 230ebcb Compare April 30, 2026 09:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

Status: Needs Review

Development

Successfully merging this pull request may close these issues.

Coin selection includes unconfirmed v3/TRUC outputs in v2 transactions, causing mempool rejection

4 participants