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
16 changes: 16 additions & 0 deletions src/wallet/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,16 @@ pub enum CreateTxError {
MissingNonWitnessUtxo(OutPoint),
/// Miniscript PSBT error
MiniscriptPsbt(MiniscriptPsbtError),
/// TRUC (BIP-431) virtual size cap exceeded.
///
/// `cap_vb == 10_000` means Rule 4 (any TRUC tx).
/// `cap_vb == 1_000` means Rule 5 (TRUC tx with unconfirmed TRUC ancestor).
TrucSizeExceeded {
/// The cap that was exceeded, in virtual bytes.
cap_vb: u64,
/// The estimated virtual size of the candidate transaction.
actual_vb: u64,
},
}

impl fmt::Display for CreateTxError {
Expand Down Expand Up @@ -281,6 +291,12 @@ impl fmt::Display for CreateTxError {
CreateTxError::MiniscriptPsbt(err) => {
write!(f, "Miniscript PSBT error: {err}")
}
CreateTxError::TrucSizeExceeded { cap_vb, actual_vb } => {
write!(
f,
"TRUC virtual size cap exceeded: estimated {actual_vb} vB > {cap_vb} vB"
)
}
}
}
}
Expand Down
65 changes: 65 additions & 0 deletions src/wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1441,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 @@ -1535,6 +1552,39 @@ 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 {
return Err(CreateTxError::TrucSizeExceeded {
cap_vb,
actual_vb: estimated_vb,
});
}
}

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

// Recording changes to the change keychain.
Expand Down Expand Up @@ -3030,6 +3080,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
241 changes: 241 additions & 0 deletions tests/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3470,3 +3470,244 @@ fn test_create_and_spend_from_truc_tx() -> anyhow::Result<()> {

Ok(())
}

// Fund the wallet with `count` confirmed P2WPKH UTXOs, each of `value` sats.
// Used by TRUC vsize tests to create wallets with many small UTXOs so that a
// `drain_wallet` v3 transaction exceeds the BIP-431 size caps.
fn fund_wallet_with_n_utxos(wallet: &mut Wallet, count: usize, value: u64) {
let block_id = wallet.latest_checkpoint().block_id();
for _ in 0..count {
let tx = Transaction {
version: transaction::Version::ONE,
lock_time: absolute::LockTime::ZERO,
input: vec![],
output: vec![TxOut {
script_pubkey: wallet
.next_unused_address(KeychainKind::External)
.script_pubkey(),
value: Amount::from_sat(value),
}],
};
let txid = tx.compute_txid();
insert_tx(wallet, tx);
insert_anchor(
wallet,
txid,
ConfirmationBlockTime {
block_id,
confirmation_time: 1,
},
);
}
}

#[test]
fn test_truc_rule_4_rejects_over_10k_vb() -> anyhow::Result<()> {
let (descriptor, change_descriptor) = get_test_wpkh_and_change_desc();
let mut wallet = Wallet::create(descriptor, change_descriptor)
.network(Network::Regtest)
.create_wallet_no_persist()?;

insert_checkpoint(
&mut wallet,
BlockId {
height: 1,
hash: BlockHash::all_zeros(),
},
);

// 200 P2WPKH inputs (~68 vB each) drained into one output exceeds
// BIP-431 Rule 4's 10,000 vB cap.
fund_wallet_with_n_utxos(&mut wallet, 200, 10_000);

let dest = wallet
.next_unused_address(KeychainKind::External)
.script_pubkey();

let mut builder = wallet.build_tx();
builder
.version(3)
.drain_wallet()
.drain_to(dest)
.fee_rate(FeeRate::from_sat_per_vb_u32(1));

assert_matches!(
builder.finish(),
Err(CreateTxError::TrucSizeExceeded { cap_vb: 10_000, .. })
);

Ok(())
}

#[test]
fn test_truc_rule_5_rejects_over_1k_vb_with_unconf_truc_ancestor() -> anyhow::Result<()> {
let (descriptor, change_descriptor) = get_test_wpkh_and_change_desc();
let mut wallet = Wallet::create(descriptor, change_descriptor)
.network(Network::Regtest)
.create_wallet_no_persist()?;

insert_checkpoint(
&mut wallet,
BlockId {
height: 1,
hash: BlockHash::all_zeros(),
},
);

// 20 confirmed P2WPKH UTXOs + 1 unconfirmed v3 UTXO. Total ~21 inputs ~ 1428 vB,
// above Rule 5's 1,000 vB cap and below Rule 4's 10,000 vB cap.
fund_wallet_with_n_utxos(&mut wallet, 20, 10_000);

let v3_unconf_parent = Transaction {
version: transaction::Version(3),
lock_time: absolute::LockTime::ZERO,
input: vec![],
output: vec![TxOut {
script_pubkey: wallet
.next_unused_address(KeychainKind::External)
.script_pubkey(),
value: Amount::from_sat(10_000),
}],
};
insert_tx(&mut wallet, v3_unconf_parent);

let dest = wallet
.next_unused_address(KeychainKind::External)
.script_pubkey();

let mut builder = wallet.build_tx();
builder
.version(3)
.drain_wallet()
.drain_to(dest)
.fee_rate(FeeRate::from_sat_per_vb_u32(1));

assert_matches!(
builder.finish(),
Err(CreateTxError::TrucSizeExceeded { cap_vb: 1_000, .. })
);

Ok(())
}

#[test]
fn test_truc_rule_5_accepts_under_1k_vb_with_unconf_truc_ancestor() -> anyhow::Result<()> {
let (descriptor, change_descriptor) = get_test_wpkh_and_change_desc();
let mut wallet = Wallet::create(descriptor, change_descriptor)
.network(Network::Regtest)
.create_wallet_no_persist()?;

insert_checkpoint(
&mut wallet,
BlockId {
height: 1,
hash: BlockHash::all_zeros(),
},
);

// Single unconfirmed v3 UTXO. 1 input ~ 68 vB, well under the 1,000 vB Rule 5 cap.
let v3_unconf_parent = Transaction {
version: transaction::Version(3),
lock_time: absolute::LockTime::ZERO,
input: vec![],
output: vec![TxOut {
script_pubkey: wallet
.next_unused_address(KeychainKind::External)
.script_pubkey(),
value: Amount::from_sat(100_000),
}],
};
insert_tx(&mut wallet, v3_unconf_parent);

let dest = wallet
.next_unused_address(KeychainKind::External)
.script_pubkey();

let mut builder = wallet.build_tx();
builder
.version(3)
.drain_wallet()
.drain_to(dest)
.fee_rate(FeeRate::from_sat_per_vb_u32(1));

let psbt = builder.finish().expect("v3 tx under 1,000 vB should build");
assert_eq!(psbt.unsigned_tx.version, transaction::Version(3));

Ok(())
}

#[test]
fn test_truc_rule_4_cap_applies_when_no_unconf_truc_ancestor() -> anyhow::Result<()> {
let (descriptor, change_descriptor) = get_test_wpkh_and_change_desc();
let mut wallet = Wallet::create(descriptor, change_descriptor)
.network(Network::Regtest)
.create_wallet_no_persist()?;

insert_checkpoint(
&mut wallet,
BlockId {
height: 1,
hash: BlockHash::all_zeros(),
},
);

// 30 confirmed UTXOs ~ 30 * 68 = 2,040 vB. Between Rule 5's cap (1,000) and
// Rule 4's cap (10,000). With no unconfirmed TRUC ancestor selected, Rule 4
// applies and the tx should build.
fund_wallet_with_n_utxos(&mut wallet, 30, 10_000);

let dest = wallet
.next_unused_address(KeychainKind::External)
.script_pubkey();

let mut builder = wallet.build_tx();
builder
.version(3)
.drain_wallet()
.drain_to(dest)
.fee_rate(FeeRate::from_sat_per_vb_u32(1));

let psbt = builder
.finish()
.expect("v3 tx between 1k and 10k vB without unconfirmed TRUC ancestor should build");
assert_eq!(psbt.unsigned_tx.version, transaction::Version(3));

Ok(())
}

#[test]
fn test_non_v3_tx_unaffected_by_truc_size_caps() -> anyhow::Result<()> {
let (descriptor, change_descriptor) = get_test_wpkh_and_change_desc();
let mut wallet = Wallet::create(descriptor, change_descriptor)
.network(Network::Regtest)
.create_wallet_no_persist()?;

insert_checkpoint(
&mut wallet,
BlockId {
height: 1,
hash: BlockHash::all_zeros(),
},
);

// Same wallet shape as the Rule 4 test (~13,600 vB), but a v2 build. TRUC rules do
// not apply so the tx must succeed.
fund_wallet_with_n_utxos(&mut wallet, 200, 10_000);

let dest = wallet
.next_unused_address(KeychainKind::External)
.script_pubkey();

let mut builder = wallet.build_tx();
builder
.drain_wallet()
.drain_to(dest)
.fee_rate(FeeRate::from_sat_per_vb_u32(1));

let psbt = builder
.finish()
.expect("v2 tx larger than 10k vB should build because TRUC rules do not apply");
assert_eq!(psbt.unsigned_tx.version, transaction::Version::TWO);

Ok(())
}