Skip to content

Commit 3acf109

Browse files
committed
fix(tx_builder)!: check foreign utxos are not local before inclusion
The new check is added to ensure all added foreign utxos are not owned by the wallet.
1 parent 739b54f commit 3acf109

File tree

2 files changed

+142
-28
lines changed

2 files changed

+142
-28
lines changed

crates/wallet/src/wallet/tx_builder.rs

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -351,8 +351,9 @@ impl<'a, Cs> TxBuilder<'a, Cs> {
351351
///
352352
/// This method returns errors in the following circumstances:
353353
///
354-
/// 1. The `psbt_input` does not contain a `witness_utxo` or `non_witness_utxo`.
355-
/// 2. The data in `non_witness_utxo` does not match what is in `outpoint`.
354+
/// 1. The provided outpoint is associated to a [`LocalOutput`].
355+
/// 2. The `psbt_input` does not contain a `witness_utxo` or `non_witness_utxo`.
356+
/// 3. The data in `non_witness_utxo` does not match what is in `outpoint`.
356357
///
357358
/// Note unless you set [`only_witness_utxo`] any non-taproot `psbt_input` you pass to this
358359
/// method must have `non_witness_utxo` set otherwise you will get an error when [`finish`]
@@ -383,24 +384,27 @@ impl<'a, Cs> TxBuilder<'a, Cs> {
383384
satisfaction_weight: Weight,
384385
sequence: Sequence,
385386
) -> Result<&mut Self, AddForeignUtxoError> {
386-
if psbt_input.witness_utxo.is_none() {
387-
match psbt_input.non_witness_utxo.as_ref() {
388-
Some(tx) => {
389-
if tx.compute_txid() != outpoint.txid {
390-
return Err(AddForeignUtxoError::InvalidTxid {
391-
input_txid: tx.compute_txid(),
392-
foreign_utxo: outpoint,
393-
});
394-
}
395-
if tx.output.len() <= outpoint.vout as usize {
396-
return Err(AddForeignUtxoError::InvalidOutpoint(outpoint));
397-
}
398-
}
399-
None => {
400-
return Err(AddForeignUtxoError::MissingUtxo);
387+
let txout = match (&psbt_input.witness_utxo, &psbt_input.non_witness_utxo) {
388+
(Some(ref txout), _) => Ok(txout.clone()),
389+
(_, Some(tx)) => {
390+
if tx.compute_txid() != outpoint.txid {
391+
Err(AddForeignUtxoError::InvalidTxid {
392+
input_txid: tx.compute_txid(),
393+
foreign_utxo: outpoint,
394+
})
395+
} else {
396+
tx.tx_out(outpoint.vout as usize)
397+
.map_err(|_| AddForeignUtxoError::InvalidOutpoint(outpoint))
398+
.cloned()
401399
}
402400
}
403-
}
401+
(_, _) => Err(AddForeignUtxoError::MissingUtxo),
402+
}?;
403+
404+
// Avoid the inclusion of local utxos as foreign utxos
405+
if self.wallet.is_mine(txout.script_pubkey) {
406+
return Err(AddForeignUtxoError::NotForeignUtxo);
407+
};
404408

405409
if let Some(WeightedUtxo {
406410
utxo: Utxo::Local { .. },
@@ -731,6 +735,8 @@ pub enum AddForeignUtxoError {
731735
InvalidOutpoint(OutPoint),
732736
/// Foreign utxo missing witness_utxo or non_witness_utxo
733737
MissingUtxo,
738+
/// UTxO is owned by wallet
739+
NotForeignUtxo,
734740
}
735741

736742
impl fmt::Display for AddForeignUtxoError {
@@ -750,6 +756,7 @@ impl fmt::Display for AddForeignUtxoError {
750756
outpoint.txid, outpoint.vout,
751757
),
752758
Self::MissingUtxo => write!(f, "Foreign utxo missing witness_utxo or non_witness_utxo"),
759+
Self::NotForeignUtxo => write!(f, "UTxO is owned by wallet"),
753760
}
754761
}
755762
}

crates/wallet/tests/wallet.rs

Lines changed: 117 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1644,18 +1644,121 @@ fn test_calculate_fee_with_missing_foreign_utxo() {
16441644
}
16451645

16461646
#[test]
1647-
fn test_add_foreign_utxo_invalid_psbt_input() {
1647+
fn test_add_foreign_utxo_fails_without_psbt_input_prev_tx_data() {
16481648
let (mut wallet, _) = get_funded_wallet_wpkh();
1649-
let outpoint = wallet.list_unspent().next().expect("must exist").outpoint;
1649+
let address = Address::from_str("bcrt1q3qtze4ys45tgdvguj66zrk4fu6hq3a3v9pfly5")
1650+
.expect("address")
1651+
.require_network(Network::Regtest)
1652+
.unwrap();
1653+
let tx0 = Transaction {
1654+
output: vec![TxOut {
1655+
value: Amount::from_sat(76_000),
1656+
script_pubkey: address.script_pubkey(),
1657+
}],
1658+
..new_tx(0)
1659+
};
1660+
let external_outpoint = OutPoint {
1661+
txid: tx0.compute_txid(),
1662+
vout: 0,
1663+
};
16501664
let foreign_utxo_satisfaction = wallet
16511665
.public_descriptor(KeychainKind::External)
16521666
.max_weight_to_satisfy()
16531667
.unwrap();
16541668

16551669
let mut builder = wallet.build_tx();
1656-
let result =
1657-
builder.add_foreign_utxo(outpoint, psbt::Input::default(), foreign_utxo_satisfaction);
1658-
assert!(matches!(result, Err(AddForeignUtxoError::MissingUtxo)));
1670+
let result = builder.add_foreign_utxo(
1671+
external_outpoint,
1672+
psbt::Input::default(),
1673+
foreign_utxo_satisfaction,
1674+
);
1675+
assert!(
1676+
matches!(result, Err(AddForeignUtxoError::MissingUtxo)),
1677+
"should fail when there is no data about the transaction creating the input"
1678+
);
1679+
}
1680+
1681+
#[test]
1682+
fn test_add_foreign_utxo_fails_when_utxo_is_owned_by_wallet_with_prev_txout() {
1683+
let (mut wallet1, _) =
1684+
get_funded_wallet_single("wpkh(cVbZ8ovhye9AoAHFsqobCf7LxbXDAECy9Kb8TZdfsDYMZGBUyCnm)");
1685+
1686+
let utxo = wallet1.list_unspent().next().expect("must take!");
1687+
let foreign_utxo_satisfaction = wallet1
1688+
.public_descriptor(KeychainKind::External)
1689+
.max_weight_to_satisfy()
1690+
.unwrap();
1691+
1692+
let psbt_input = psbt::Input {
1693+
// Add txout
1694+
witness_utxo: Some(utxo.txout.clone()),
1695+
..Default::default()
1696+
};
1697+
1698+
let mut builder = wallet1.build_tx();
1699+
let result = builder.add_foreign_utxo(utxo.outpoint, psbt_input, foreign_utxo_satisfaction);
1700+
assert!(
1701+
matches!(result, Err(AddForeignUtxoError::NotForeignUtxo)),
1702+
"should fail when the outpoint provided is not foreign to the wallet"
1703+
);
1704+
}
1705+
1706+
#[test]
1707+
fn test_add_foreign_utxo_fails_when_utxo_is_owned_by_wallet_with_full_prev_tx() {
1708+
let (mut wallet1, txid1) =
1709+
get_funded_wallet_single("wpkh(cVbZ8ovhye9AoAHFsqobCf7LxbXDAECy9Kb8TZdfsDYMZGBUyCnm)");
1710+
1711+
let utxo = wallet1.list_unspent().next().expect("must take!");
1712+
let full_tx = wallet1.get_tx(txid1).unwrap().tx_node.tx.clone();
1713+
let foreign_utxo_satisfaction = wallet1
1714+
.public_descriptor(KeychainKind::External)
1715+
.max_weight_to_satisfy()
1716+
.unwrap();
1717+
1718+
let psbt_input = psbt::Input {
1719+
// Add full transaction
1720+
non_witness_utxo: Some(full_tx.as_ref().clone()),
1721+
..Default::default()
1722+
};
1723+
1724+
let mut builder = wallet1.build_tx();
1725+
let result = builder.add_foreign_utxo(utxo.outpoint, psbt_input, foreign_utxo_satisfaction);
1726+
assert!(
1727+
matches!(result, Err(AddForeignUtxoError::NotForeignUtxo)),
1728+
"should fail when the outpoint provided is not foreign to the wallet"
1729+
);
1730+
}
1731+
1732+
#[test]
1733+
fn test_add_foreign_utxo_fails_with_invalid_outpoint_vout() {
1734+
let (mut wallet1, txid1) =
1735+
get_funded_wallet_single("wpkh(cVbZ8ovhye9AoAHFsqobCf7LxbXDAECy9Kb8TZdfsDYMZGBUyCnm)");
1736+
1737+
let utxo = wallet1.list_unspent().next().expect("must take!");
1738+
let full_tx = wallet1.get_tx(txid1).unwrap().tx_node.tx.clone();
1739+
let foreign_utxo_satisfaction = wallet1
1740+
.public_descriptor(KeychainKind::External)
1741+
.max_weight_to_satisfy()
1742+
.unwrap();
1743+
1744+
let psbt_input = psbt::Input {
1745+
non_witness_utxo: Some(full_tx.as_ref().clone()),
1746+
..Default::default()
1747+
};
1748+
1749+
let mut builder = wallet1.build_tx();
1750+
let result = builder.add_foreign_utxo(
1751+
OutPoint {
1752+
vout: full_tx.output.len() as u32 + 1,
1753+
txid: utxo.outpoint.txid,
1754+
},
1755+
psbt_input,
1756+
foreign_utxo_satisfaction,
1757+
);
1758+
assert!(
1759+
matches!(result, Err(AddForeignUtxoError::InvalidOutpoint { .. })),
1760+
"should fail when the outpoint provided index is not one of the possible transaction vouts"
1761+
);
16591762
}
16601763

16611764
#[test]
@@ -1674,19 +1777,23 @@ fn test_add_foreign_utxo_where_outpoint_doesnt_match_psbt_input() {
16741777
.unwrap();
16751778

16761779
let mut builder = wallet1.build_tx();
1780+
1781+
// Check we are activating the code path that should rise the error
16771782
assert!(
1678-
builder
1679-
.add_foreign_utxo(
1783+
matches!(
1784+
builder.add_foreign_utxo(
16801785
utxo2.outpoint,
16811786
psbt::Input {
16821787
non_witness_utxo: Some(tx1.as_ref().clone()),
16831788
..Default::default()
16841789
},
16851790
satisfaction_weight
1686-
)
1687-
.is_err(),
1688-
"should fail when outpoint doesn't match psbt_input"
1791+
),
1792+
Err(AddForeignUtxoError::InvalidTxid { .. })
1793+
),
1794+
"should fail when outpoint txid doesn't match psbt_input txout txid"
16891795
);
1796+
16901797
assert!(
16911798
builder
16921799
.add_foreign_utxo(

0 commit comments

Comments
 (0)