Skip to content

Commit a422a2d

Browse files
committed
feat: expose detailed try_finalize_psbt outcomes
1 parent fb7681a commit a422a2d

2 files changed

Lines changed: 297 additions & 20 deletions

File tree

src/wallet/mod.rs

Lines changed: 114 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,55 @@ impl fmt::Display for AddressInfo {
184184
/// A `CanonicalTx` managed by a `Wallet`.
185185
pub type WalletTx<'a> = CanonicalTx<'a, Arc<Transaction>, ConfirmationBlockTime>;
186186

187+
/// The finalization status for a single PSBT input.
188+
#[derive(Debug, PartialEq)]
189+
pub enum FinalizeInputOutcome {
190+
/// The input was already finalized before this call.
191+
AlreadyFinalized,
192+
/// The input was successfully finalized during this call.
193+
Finalized,
194+
/// The wallet could not derive a descriptor for the input.
195+
MissingDescriptor,
196+
/// The wallet found the descriptor but could not construct the input satisfaction.
197+
CouldNotSatisfy(miniscript::Error),
198+
}
199+
200+
impl FinalizeInputOutcome {
201+
/// Whether the input is finalized after this call.
202+
pub fn is_finalized(&self) -> bool {
203+
matches!(self, Self::AlreadyFinalized | Self::Finalized)
204+
}
205+
}
206+
207+
/// Holds per-input PSBT finalization outcomes.
208+
#[derive(Debug, PartialEq)]
209+
pub struct FinalizedInputs {
210+
outcomes: BTreeMap<usize, FinalizeInputOutcome>,
211+
}
212+
213+
impl FinalizedInputs {
214+
fn new(outcomes: BTreeMap<usize, FinalizeInputOutcome>) -> Self {
215+
Self { outcomes }
216+
}
217+
218+
/// Whether all inputs are finalized after this call.
219+
pub fn is_finalized(&self) -> bool {
220+
self.outcomes
221+
.values()
222+
.all(FinalizeInputOutcome::is_finalized)
223+
}
224+
225+
/// Borrow the per-input finalization outcomes.
226+
pub fn outcomes(&self) -> &BTreeMap<usize, FinalizeInputOutcome> {
227+
&self.outcomes
228+
}
229+
230+
/// Consume the collection and return the per-input finalization outcomes.
231+
pub fn into_outcomes(self) -> BTreeMap<usize, FinalizeInputOutcome> {
232+
self.outcomes
233+
}
234+
}
235+
187236
impl Wallet {
188237
/// Build a new single descriptor [`Wallet`].
189238
///
@@ -1866,23 +1915,55 @@ impl Wallet {
18661915
}
18671916
})
18681917
.collect::<HashMap<Txid, u32>>();
1918+
let current_height = sign_options
1919+
.assume_height
1920+
.unwrap_or_else(|| self.chain.tip().height());
1921+
1922+
Ok(self
1923+
.try_finalize_psbt_with(psbt, |_, input| {
1924+
Some((
1925+
current_height,
1926+
confirmation_heights
1927+
.get(&input.previous_output.txid)
1928+
.copied(),
1929+
))
1930+
})?
1931+
.is_finalized())
1932+
}
1933+
1934+
/// Finalize a PSBT and return per-input finalization results. Use this method when you need to
1935+
/// inspect why a specific input could not be finalized.
1936+
///
1937+
/// The method should only return `Err` when the PSBT is malformed, for example if its inputs
1938+
/// are out of bounds.
1939+
pub fn try_finalize_psbt(
1940+
&self,
1941+
psbt: &mut Psbt,
1942+
) -> Result<FinalizedInputs, IndexOutOfBoundsError> {
1943+
self.try_finalize_psbt_with(psbt, |_, _| None)
1944+
}
18691945

1870-
let mut finished = true;
1946+
fn try_finalize_psbt_with<F>(
1947+
&self,
1948+
psbt: &mut Psbt,
1949+
mut wallet_timelocks: F,
1950+
) -> Result<FinalizedInputs, IndexOutOfBoundsError>
1951+
where
1952+
F: FnMut(usize, &bitcoin::TxIn) -> Option<(u32, Option<u32>)>,
1953+
{
1954+
let tx = &psbt.unsigned_tx;
1955+
1956+
let mut outcomes = BTreeMap::new();
18711957

18721958
for (n, input) in tx.input.iter().enumerate() {
18731959
let psbt_input = &psbt
18741960
.inputs
18751961
.get(n)
18761962
.ok_or(IndexOutOfBoundsError::new(n, psbt.inputs.len()))?;
18771963
if psbt_input.final_script_sig.is_some() || psbt_input.final_script_witness.is_some() {
1964+
outcomes.insert(n, FinalizeInputOutcome::AlreadyFinalized);
18781965
continue;
18791966
}
1880-
let confirmation_height = confirmation_heights
1881-
.get(&input.previous_output.txid)
1882-
.copied();
1883-
let current_height = sign_options
1884-
.assume_height
1885-
.unwrap_or_else(|| self.chain.tip().height());
18861967

18871968
// - Try to derive the descriptor by looking at the txout. If it's in our database, we
18881969
// know exactly which `keychain` to use, and which derivation index it is.
@@ -1902,14 +1983,22 @@ impl Wallet {
19021983
match desc {
19031984
Some(desc) => {
19041985
let mut tmp_input = bitcoin::TxIn::default();
1905-
match desc.satisfy(
1906-
&mut tmp_input,
1907-
(
1908-
PsbtInputSatisfier::new(psbt, n),
1909-
After::new(Some(current_height), false),
1910-
Older::new(Some(current_height), confirmation_height, false),
1911-
),
1912-
) {
1986+
let satisfy_result = if let Some((current_height, confirmation_height)) =
1987+
wallet_timelocks(n, input)
1988+
{
1989+
desc.satisfy(
1990+
&mut tmp_input,
1991+
(
1992+
PsbtInputSatisfier::new(psbt, n),
1993+
After::new(Some(current_height), false),
1994+
Older::new(Some(current_height), confirmation_height, false),
1995+
),
1996+
)
1997+
} else {
1998+
desc.satisfy(&mut tmp_input, PsbtInputSatisfier::new(psbt, n))
1999+
};
2000+
2001+
match satisfy_result {
19132002
Ok(_) => {
19142003
let length = psbt.inputs.len();
19152004
// Set the UTXO fields, final script_sig and witness
@@ -1927,23 +2016,29 @@ impl Wallet {
19272016
if !tmp_input.witness.is_empty() {
19282017
psbt_input.final_script_witness = Some(tmp_input.witness);
19292018
}
2019+
outcomes.insert(n, FinalizeInputOutcome::Finalized);
2020+
}
2021+
Err(err) => {
2022+
outcomes.insert(n, FinalizeInputOutcome::CouldNotSatisfy(err));
19302023
}
1931-
Err(_) => finished = false,
19322024
}
19332025
}
1934-
None => finished = false,
2026+
None => {
2027+
outcomes.insert(n, FinalizeInputOutcome::MissingDescriptor);
2028+
}
19352029
}
19362030
}
19372031

19382032
// Clear derivation paths from outputs.
1939-
if finished {
2033+
let finalized = FinalizedInputs::new(outcomes);
2034+
if finalized.is_finalized() {
19402035
for output in &mut psbt.outputs {
19412036
output.bip32_derivation.clear();
19422037
output.tap_key_origins.clear();
19432038
}
19442039
}
19452040

1946-
Ok(finished)
2041+
Ok(finalized)
19472042
}
19482043

19492044
/// Return the secp256k1 context used for all signing operations.

tests/wallet.rs

Lines changed: 183 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ use bdk_wallet::psbt::PsbtUtils;
1010
use bdk_wallet::signer::{SignOptions, SignerError};
1111
use bdk_wallet::test_utils::*;
1212
use bdk_wallet::KeychainKind;
13-
use bdk_wallet::{AddressInfo, Balance, PersistedWallet, Update, Wallet, WalletTx};
13+
use bdk_wallet::{
14+
AddressInfo, Balance, FinalizeInputOutcome, IndexOutOfBoundsError, PersistedWallet, Update,
15+
Wallet, WalletTx,
16+
};
1417
use bitcoin::constants::COINBASE_MATURITY;
1518
use bitcoin::hashes::Hash;
1619
use bitcoin::script::PushBytesBuf;
@@ -1560,6 +1563,185 @@ fn test_try_finalize_sign_option() {
15601563
}
15611564
}
15621565

1566+
#[test]
1567+
fn test_try_finalize_psbt_outcomes() {
1568+
{
1569+
let (mut wallet, _) = get_funded_wallet_single(get_test_wpkh());
1570+
let addr = wallet.next_unused_address(KeychainKind::External);
1571+
let mut builder = wallet.build_tx();
1572+
builder.drain_to(addr.script_pubkey()).drain_wallet();
1573+
let mut psbt = builder.finish().unwrap();
1574+
1575+
let is_final = wallet
1576+
.sign(
1577+
&mut psbt,
1578+
SignOptions {
1579+
try_finalize: false,
1580+
..Default::default()
1581+
},
1582+
)
1583+
.unwrap();
1584+
assert!(!is_final);
1585+
assert!(
1586+
psbt.outputs
1587+
.iter()
1588+
.any(|output| !output.bip32_derivation.is_empty()),
1589+
"expected wallet-owned outputs to retain derivation data before finalization"
1590+
);
1591+
1592+
let finalized = wallet.try_finalize_psbt(&mut psbt).unwrap();
1593+
1594+
assert!(finalized.is_finalized());
1595+
assert_matches!(
1596+
finalized.outcomes().get(&0),
1597+
Some(FinalizeInputOutcome::Finalized)
1598+
);
1599+
assert!(
1600+
psbt.inputs[0].final_script_sig.is_some()
1601+
|| psbt.inputs[0].final_script_witness.is_some()
1602+
);
1603+
assert!(psbt
1604+
.outputs
1605+
.iter()
1606+
.all(|output| output.bip32_derivation.is_empty()));
1607+
1608+
let finalized = wallet.try_finalize_psbt(&mut psbt).unwrap();
1609+
1610+
assert!(finalized.is_finalized());
1611+
assert_matches!(
1612+
finalized.outcomes().get(&0),
1613+
Some(FinalizeInputOutcome::AlreadyFinalized)
1614+
);
1615+
}
1616+
1617+
{
1618+
let (mut wallet, _) = get_funded_wallet_single(get_test_wpkh());
1619+
let addr = wallet.next_unused_address(KeychainKind::External);
1620+
let mut builder = wallet.build_tx();
1621+
builder.add_recipient(addr.script_pubkey(), Amount::from_sat(25_000));
1622+
let mut psbt = builder.finish().unwrap();
1623+
1624+
let dud_input = bitcoin::psbt::Input {
1625+
witness_utxo: Some(TxOut {
1626+
value: Amount::from_sat(100_000),
1627+
script_pubkey: miniscript::Descriptor::<bitcoin::PublicKey>::from_str(
1628+
"wpkh(025476c2e83188368da1ff3e292e7acafcdb3566bb0ad253f62fc70f07aeee6357)",
1629+
)
1630+
.unwrap()
1631+
.script_pubkey(),
1632+
}),
1633+
..Default::default()
1634+
};
1635+
1636+
psbt.inputs.push(dud_input);
1637+
psbt.unsigned_tx.input.push(bitcoin::TxIn::default());
1638+
1639+
let finalized = wallet.try_finalize_psbt(&mut psbt).unwrap();
1640+
1641+
assert!(!finalized.is_finalized());
1642+
assert_matches!(
1643+
finalized.outcomes().get(&0),
1644+
Some(FinalizeInputOutcome::CouldNotSatisfy(
1645+
bdk_wallet::miniscript::Error::MissingSig(_)
1646+
))
1647+
);
1648+
assert_matches!(
1649+
finalized.outcomes().get(&1),
1650+
Some(FinalizeInputOutcome::MissingDescriptor)
1651+
);
1652+
}
1653+
}
1654+
1655+
#[test]
1656+
fn test_try_finalize_psbt_returns_index_out_of_bounds_for_malformed_psbt() {
1657+
let (mut wallet, _) = get_funded_wallet_single(get_test_wpkh());
1658+
let addr = wallet.next_unused_address(KeychainKind::External);
1659+
let mut builder = wallet.build_tx();
1660+
builder.drain_to(addr.script_pubkey()).drain_wallet();
1661+
let mut psbt = builder.finish().unwrap();
1662+
1663+
psbt.inputs.clear();
1664+
1665+
let err = wallet.try_finalize_psbt(&mut psbt).unwrap_err();
1666+
1667+
assert_eq!(err, IndexOutOfBoundsError::new(0, 0));
1668+
}
1669+
1670+
#[test]
1671+
fn test_try_finalize_psbt_uses_psbt_timelocks() {
1672+
{
1673+
let (mut wallet, _) = get_funded_wallet_single(get_test_single_sig_cltv());
1674+
let addr = wallet.next_unused_address(KeychainKind::External);
1675+
let mut builder = wallet.build_tx();
1676+
builder.add_recipient(addr.script_pubkey(), Amount::from_sat(25_000));
1677+
let mut psbt = builder.finish().unwrap();
1678+
1679+
wallet
1680+
.sign(
1681+
&mut psbt,
1682+
SignOptions {
1683+
try_finalize: false,
1684+
..Default::default()
1685+
},
1686+
)
1687+
.unwrap();
1688+
1689+
let mut valid_psbt = psbt.clone();
1690+
assert!(wallet
1691+
.try_finalize_psbt(&mut valid_psbt)
1692+
.unwrap()
1693+
.is_finalized());
1694+
1695+
psbt.unsigned_tx.lock_time = absolute::LockTime::from_height(0).unwrap();
1696+
1697+
let finalized = wallet.try_finalize_psbt(&mut psbt).unwrap();
1698+
1699+
assert!(!finalized.is_finalized());
1700+
assert_matches!(
1701+
finalized.outcomes().get(&0),
1702+
Some(FinalizeInputOutcome::CouldNotSatisfy(_))
1703+
);
1704+
assert!(psbt.inputs[0].final_script_sig.is_none());
1705+
assert!(psbt.inputs[0].final_script_witness.is_none());
1706+
}
1707+
1708+
{
1709+
let (mut wallet, _) = get_funded_wallet_single(get_test_single_sig_csv());
1710+
let addr = wallet.next_unused_address(KeychainKind::External);
1711+
let mut builder = wallet.build_tx();
1712+
builder.add_recipient(addr.script_pubkey(), Amount::from_sat(25_000));
1713+
let mut psbt = builder.finish().unwrap();
1714+
1715+
wallet
1716+
.sign(
1717+
&mut psbt,
1718+
SignOptions {
1719+
try_finalize: false,
1720+
..Default::default()
1721+
},
1722+
)
1723+
.unwrap();
1724+
1725+
let mut valid_psbt = psbt.clone();
1726+
assert!(wallet
1727+
.try_finalize_psbt(&mut valid_psbt)
1728+
.unwrap()
1729+
.is_finalized());
1730+
1731+
psbt.unsigned_tx.input[0].sequence = Sequence::MAX;
1732+
1733+
let finalized = wallet.try_finalize_psbt(&mut psbt).unwrap();
1734+
1735+
assert!(!finalized.is_finalized());
1736+
assert_matches!(
1737+
finalized.outcomes().get(&0),
1738+
Some(FinalizeInputOutcome::CouldNotSatisfy(_))
1739+
);
1740+
assert!(psbt.inputs[0].final_script_sig.is_none());
1741+
assert!(psbt.inputs[0].final_script_witness.is_none());
1742+
}
1743+
}
1744+
15631745
#[test]
15641746
fn test_taproot_try_finalize_sign_option() {
15651747
let (mut wallet, _) = get_funded_wallet_single(get_test_tr_with_taptree());

0 commit comments

Comments
 (0)