Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 98 additions & 5 deletions src/wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ use bitcoin::{
psbt,
secp256k1::Secp256k1,
sighash::{EcdsaSighashType, TapSighashType},
transaction, Address, Amount, Block, FeeRate, Network, NetworkKind, OutPoint, Psbt, ScriptBuf,
Sequence, SignedAmount, Transaction, TxOut, Txid, Weight, Witness,
transaction::{self, Version},
Address, Amount, Block, FeeRate, Network, NetworkKind, OutPoint, Psbt, ScriptBuf, Sequence,
SignedAmount, Transaction, TxOut, Txid, Weight, Witness,
};
use miniscript::{
descriptor::KeyMap,
Expand Down Expand Up @@ -1429,7 +1430,7 @@ impl Wallet {
let (required_utxos, optional_utxos) = {
// 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);

// If `drain_wallet` is true, all UTxOs are required.
if params.drain_wallet {
Expand All @@ -1440,6 +1441,23 @@ impl Wallet {
}
};

const TRUC_MAX_VSIZE_VB: u64 = 10_000;
const TRUC_CHILD_MAX_VSIZE_VB: u64 = 1_000;

let is_truc_tx = is_truc(version);

// BIP-431: keep per-input satisfaction weights for the vsize check below;
// coin_select returns plain Utxos and the weights would otherwise be lost.
let satisfaction_weights: HashMap<OutPoint, Weight> = if is_truc_tx {
required_utxos
.iter()
.chain(optional_utxos.iter())
.map(|w| (w.utxo.outpoint(), w.satisfaction_weight))
.collect()
} else {
HashMap::new()
};

// Get drain script.
let mut drain_index = Option::<(KeychainKind, u32)>::None;
let drain_script = match params.drain_to {
Expand Down Expand Up @@ -1534,6 +1552,40 @@ impl Wallet {
// Sort inputs/outputs according to the chosen algorithm.
params.ordering.sort_tx_with_aux_rand(&mut tx, rng);

// BIP-431 Rules 4 and 5: a TRUC transaction's sigop-adjusted vsize is capped at
// 10,000 vB, or 1,000 vB when it has an unconfirmed TRUC ancestor.
if is_truc_tx {
let total_satisfaction_weight: Weight = coin_selection
.selected
.iter()
.filter_map(|u| satisfaction_weights.get(&u.outpoint()).copied())
.sum();
let estimated_vb = estimate_truc_vsize(tx.weight(), total_satisfaction_weight);

let has_unconf_truc_ancestor = coin_selection.selected.iter().any(|utxo| match utxo {
Utxo::Local(local) if local.chain_position.is_unconfirmed() => self
.tx_graph
.graph()
.get_tx(local.outpoint.txid)
.is_some_and(|tx| is_truc(tx.version)),
// Foreign UTXOs carry no chain position; treat them as non-TRUC.
Utxo::Local(..) | Utxo::Foreign { .. } => false,
});

let cap_vb = if has_unconf_truc_ancestor {
TRUC_CHILD_MAX_VSIZE_VB
} else {
TRUC_MAX_VSIZE_VB
};
if estimated_vb > cap_vb {
let available = coin_selection.selected_amount();
return Err(CreateTxError::CoinSelection(InsufficientFunds {
needed: available + Amount::from_sat(1),
available,
}));
Comment on lines +1580 to +1585

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.

I'm worried this won't adequately convey the true cause of the error - that the vsize limit was exceeded. Probably the right way is to pass a max-weight parameter to a coin selector which selects inputs while the total weight is within the size limit or fails if the cap is exceeded before the target is funded.

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.

I agree with the concern.

I talked with @oleonardolima too, and our current thinking is to keep this PR non-breaking. InsufficientFunds is not the ideal error, but adding TrucSizeExceeded would be a breaking API change

Longer term, I agree this should probably be modeled as a coin-selection constraint, not just a post-selection check

And AFAIK bdk_tx is still being built, that maybe would be better for this kind of policy-aware transaction construction

For now, I’d like to keep this PR as the minimal non-breaking guard because Second/Ark users need Rules 4/5 enforced now, and without this BDK can return PSBTs that bitcoind rejects

Happy to track the cleaner design as a follow-up

}
}

let psbt = self.complete_transaction(tx, coin_selection.selected, params)?;

// Recording changes to the change keychain.
Expand Down Expand Up @@ -2095,7 +2147,12 @@ impl Wallet {

/// Given the options returns the list of utxos that must be used to form the
/// transaction and any further that may be used if needed.
fn filter_utxos(&self, params: &TxParams, current_height: u32) -> Vec<WeightedUtxo> {
fn filter_utxos(
&self,
params: &TxParams,
current_height: u32,
version: Version,
) -> Vec<WeightedUtxo> {
if params.manually_selected_only {
vec![]
// Only process optional UTxOs if manually_selected_only is false.
Expand All @@ -2105,6 +2162,7 @@ impl Wallet {
.iter()
.map(|wutxo| wutxo.utxo.outpoint())
.collect::<HashSet<OutPoint>>();

self.tx_graph
.graph()
// Get all unspent UTxOs from wallet.
Expand All @@ -2124,6 +2182,21 @@ impl Wallet {
.is_mature(current_height)
.then(|| new_local_utxo(k, i, full_txo))
})
// Only add to optional UTXOs those that follows BIP-431 (TRUC) specification.
// see https://github.com/bitcoin/bips/blob/master/bip-0431.mediawiki#specification
.filter(|local_output| {
// If the output is confirmed, it can be spent by either non-TRUC/TRUC
// transactions.
if local_output.chain_position.is_confirmed() {
return true;
}

// If building TRUC (V3), the unconfirmed outputs MUST be TRUC (V3). Otherwise,
// if building a non-TRUC, the unconfirmed outputs MUST be non-TRUC.
self.tx_graph()
.get_tx(local_output.outpoint.txid)
.is_some_and(|tx| (tx.version == Version(3)) == (version == Version(3)))
})
// only process UTXOs not selected manually, they will be considered later in the
// chain
// NOTE: this avoid UTXOs in both required and optional list
Expand Down Expand Up @@ -3008,6 +3081,21 @@ fn make_indexed_graph(
Ok(indexed_graph)
}

/// Check if the given [`transaction::Version`] is TRUC (Topologically Restricted Until
/// Confirmation).
fn is_truc(version: transaction::Version) -> bool {
version.eq(&Version(3))
}

/// Estimate the post-signing virtual size of a transaction in vB.
///
/// Returns plain `weight / 4`, not the sigop-adjusted vsize bitcoind applies to TRUC
/// policy. The two coincide for all common descriptors (P2WPKH, P2TR, P2WSH); see #477
/// for proper sigop accounting.
fn estimate_truc_vsize(unsigned_tx_weight: Weight, satisfaction_weight: Weight) -> u64 {
(unsigned_tx_weight + satisfaction_weight).to_vbytes_ceil()
}

/// Transforms a [`FeeRate`] to `f64` with unit as sat/vb.
#[macro_export]
#[doc(hidden)]
Expand Down Expand Up @@ -3104,8 +3192,13 @@ mod test {
let mut builder = wallet.build_tx();
builder.add_utxo(outpoint).expect("should add local utxo");
let params = builder.params.clone();
let version = params.version.unwrap_or(Version::TWO);
// enforce selection of first output in transaction
let received = wallet.filter_utxos(&params, wallet.latest_checkpoint().block_id().height);
let received = wallet.filter_utxos(
&params,
wallet.latest_checkpoint().block_id().height,
version,
);
// Notice expected doesn't include the first output from two_output_tx as it should be
// filtered out.
let expected = vec![wallet
Expand Down
8 changes: 8 additions & 0 deletions src/wallet/tx_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,10 @@ impl<'a, Cs> TxBuilder<'a, Cs> {
///
/// These have priority over the "unspendable" UTXOs, meaning that if a UTXO is present both in
/// the "UTXOs" and the "unspendable" list, it will be spent.
///
/// Manually selected UTXOs bypass optional-UTXO filtering (for example TRUC version
/// compatibility checks). Callers must ensure any manually selected unconfirmed UTXO is valid
/// for the transaction version being built.
pub fn add_utxo(&mut self, outpoint: OutPoint) -> Result<&mut Self, AddUtxoError> {
self.add_utxos(&[outpoint])
}
Expand All @@ -339,6 +343,10 @@ impl<'a, Cs> TxBuilder<'a, Cs> {
/// A manually added local UTXO will always have greater precedence than a foreign UTXO. No
/// matter if it was added before or after the foreign UTXO.
///
/// Manually selected UTXOs bypass optional-UTXO filtering (for example TRUC version
/// compatibility checks). Callers must ensure any manually selected unconfirmed UTXO is valid
/// for the transaction version being built.
///
/// At a minimum to add a foreign UTXO we need:
///
/// 1. `outpoint`: To add it to the raw transaction.
Expand Down
Loading