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
109 changes: 104 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,
}));
}
}

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

// Recording changes to the change keychain.
Expand Down Expand Up @@ -2029,7 +2081,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 @@ -2039,6 +2096,7 @@ impl Wallet {
.iter()
.map(|wutxo| wutxo.utxo.outpoint())
.collect::<HashSet<OutPoint>>();

self.tx_graph
.graph()
// Get all unspent UTxOs from wallet.
Expand All @@ -2058,6 +2116,27 @@ 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 local_output.chain_position.is_confirmed() {
return true;
}

let Some(ancestor_tx) = self.tx_graph().get_tx(local_output.outpoint.txid)
// if we don't have the full tx available we can't assure the ancestor
// tx version it assumes it's a valid candidate.
else {
return true;
};

match is_truc(version) {
// if building TRUC; filter out all unconfirmed non-TRUC.
true => is_truc(ancestor_tx.version),
// if building non-TRUC; filter out all unconfirmed TRUC.
false => !is_truc(ancestor_tx.version),
}
})
// 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 @@ -2942,6 +3021,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 @@ -3038,8 +3132,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
Loading