-
Notifications
You must be signed in to change notification settings - Fork 88
feat: expose detailed try_finalize_psbt outcomes #433
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
6afd006
50a1a0f
7f8cbca
8a7b5de
ec3a683
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,8 @@ use chain::{ChainPosition, ConfirmationBlockTime}; | |
| use core::convert::AsRef; | ||
| use core::fmt; | ||
|
|
||
| use crate::collections::BTreeMap; | ||
|
|
||
| use bitcoin::transaction::{OutPoint, Sequence, TxOut}; | ||
| use bitcoin::{psbt, Weight}; | ||
|
|
||
|
|
@@ -141,6 +143,55 @@ impl Utxo { | |
| } | ||
| } | ||
|
|
||
| /// The finalization status for a single PSBT input. | ||
| #[derive(Debug, PartialEq)] | ||
| pub enum FinalizeInputOutcome { | ||
| /// The input was already finalized before this call. | ||
| AlreadyFinalized, | ||
| /// The input was successfully finalized during this call. | ||
| Finalized, | ||
| /// The wallet could not derive a descriptor for the input. | ||
| MissingDescriptor, | ||
| /// The wallet found the descriptor but could not construct the input satisfaction. | ||
| CouldNotSatisfy(miniscript::Error), | ||
| } | ||
|
|
||
| impl FinalizeInputOutcome { | ||
| /// Whether the input is finalized after this call. | ||
| pub fn is_finalized(&self) -> bool { | ||
| matches!(self, Self::AlreadyFinalized | Self::Finalized) | ||
| } | ||
| } | ||
|
|
||
| /// The outcome of a PSBT finalization attempt. | ||
| #[derive(Debug, PartialEq)] | ||
| pub struct FinalizePsbtOutcome { | ||
| outcomes: BTreeMap<usize, FinalizeInputOutcome>, | ||
| } | ||
|
|
||
| impl FinalizePsbtOutcome { | ||
| pub(crate) fn new(outcomes: BTreeMap<usize, FinalizeInputOutcome>) -> Self { | ||
| Self { outcomes } | ||
| } | ||
|
|
||
| /// Whether all inputs are finalized after this call. | ||
| pub fn is_finalized(&self) -> bool { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If outcomes is empty (e.g. a PSBT with no inputs), is_finalized() returns true, since .all() on an empty iterator is true. Is this the intended behavior or worth documenting/guarding against explicitly?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is intended since |
||
| self.outcomes | ||
| .values() | ||
| .all(FinalizeInputOutcome::is_finalized) | ||
| } | ||
|
|
||
| /// Borrow the per-input finalization outcomes. | ||
| pub fn outcomes(&self) -> &BTreeMap<usize, FinalizeInputOutcome> { | ||
| &self.outcomes | ||
| } | ||
|
|
||
| /// Consume the collection and return the per-input finalization outcomes. | ||
| pub fn into_outcomes(self) -> BTreeMap<usize, FinalizeInputOutcome> { | ||
| self.outcomes | ||
| } | ||
| } | ||
|
|
||
| /// Index out of bounds error. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| pub struct IndexOutOfBoundsError { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1917,23 +1917,71 @@ impl Wallet { | |
| } | ||
| }) | ||
| .collect::<HashMap<Txid, u32>>(); | ||
| let current_height = sign_options | ||
| .assume_height | ||
| .unwrap_or_else(|| self.chain.tip().height()); | ||
|
|
||
| Ok(self | ||
| .try_finalize_psbt_with( | ||
| psbt, | ||
| Some(current_height), | ||
| |_, input| { | ||
| confirmation_heights | ||
| .get(&input.previous_output.txid) | ||
| .copied() | ||
| }, | ||
| true, | ||
| )? | ||
| .is_finalized()) | ||
| } | ||
|
|
||
| /// Attempt to finalize each input of a PSBT and return per-input finalization results. | ||
| /// | ||
| /// Use this method when you need to inspect why a specific input could not be finalized. Call | ||
| /// [`FinalizePsbtOutcome::is_finalized`] on the returned value to check whether all inputs are | ||
| /// finalized after the call. | ||
| /// | ||
| /// Per-input finalization failures are reported as [`FinalizeInputOutcome`] values. This method | ||
| /// only returns `Err` when the PSBT is malformed, for example if its inputs are out of bounds. | ||
| /// | ||
| /// Timelock satisfaction is evaluated from the PSBT transaction fields. This method does not | ||
| /// redact or clear output metadata. | ||
| pub fn try_finalize_psbt( | ||
| &self, | ||
| psbt: &mut Psbt, | ||
| ) -> Result<FinalizePsbtOutcome, IndexOutOfBoundsError> { | ||
| self.try_finalize_psbt_with(psbt, None, |_, _| None, false) | ||
| } | ||
|
|
||
| fn try_finalize_psbt_with<F>( | ||
| &self, | ||
| psbt: &mut Psbt, | ||
| current_height: Option<u32>, | ||
| mut confirmation_height_for_input: F, | ||
|
oleonardolima marked this conversation as resolved.
|
||
| clear_output_derivations: bool, | ||
|
oleonardolima marked this conversation as resolved.
|
||
| ) -> Result<FinalizePsbtOutcome, IndexOutOfBoundsError> | ||
| where | ||
| F: FnMut(usize, &bitcoin::TxIn) -> Option<u32>, | ||
| { | ||
| let tx = &psbt.unsigned_tx; | ||
| if psbt.inputs.len() < tx.input.len() { | ||
| return Err(IndexOutOfBoundsError::new( | ||
| psbt.inputs.len(), | ||
| psbt.inputs.len(), | ||
| )); | ||
| } | ||
|
|
||
| let mut finished = true; | ||
| let mut outcomes = BTreeMap::new(); | ||
|
|
||
| for (n, input) in tx.input.iter().enumerate() { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If psbt.inputs.len() < tx.input.len() but not 0, this loop will partially mutate earlier inputs before hitting the IndexOutOfBoundsError on a later one. Is it expected to have a partially-mutated PSBT on Err , or should length be validated up front before any mutation starts?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agree I think validating up front is cleaner here. 6afd006 adds a guard before the loop so a PSBT with missing input metadata returns |
||
| let psbt_input = &psbt | ||
| .inputs | ||
| .get(n) | ||
| .ok_or(IndexOutOfBoundsError::new(n, psbt.inputs.len()))?; | ||
| if psbt_input.final_script_sig.is_some() || psbt_input.final_script_witness.is_some() { | ||
| outcomes.insert(n, FinalizeInputOutcome::AlreadyFinalized); | ||
| continue; | ||
| } | ||
| let confirmation_height = confirmation_heights | ||
| .get(&input.previous_output.txid) | ||
| .copied(); | ||
| let current_height = sign_options | ||
| .assume_height | ||
| .unwrap_or_else(|| self.chain.tip().height()); | ||
|
|
||
| // - Try to derive the descriptor by looking at the txout. If it's in our database, we | ||
| // know exactly which `keychain` to use, and which derivation index it is. | ||
|
|
@@ -1953,48 +2001,66 @@ impl Wallet { | |
| match desc { | ||
| Some(desc) => { | ||
| let mut tmp_input = bitcoin::TxIn::default(); | ||
| match desc.satisfy( | ||
| &mut tmp_input, | ||
| ( | ||
| PsbtInputSatisfier::new(psbt, n), | ||
| After::new(Some(current_height), false), | ||
| Older::new(Some(current_height), confirmation_height, false), | ||
| ), | ||
| ) { | ||
| let satisfy_result = if let Some(current_height) = current_height { | ||
| let confirmation_height = confirmation_height_for_input(n, input); | ||
| desc.satisfy( | ||
| &mut tmp_input, | ||
| ( | ||
| PsbtInputSatisfier::new(psbt, n), | ||
| After::new(Some(current_height), false), | ||
| Older::new(Some(current_height), confirmation_height, false), | ||
| ), | ||
| ) | ||
| } else { | ||
| desc.satisfy(&mut tmp_input, PsbtInputSatisfier::new(psbt, n)) | ||
| }; | ||
|
|
||
| match satisfy_result { | ||
| Ok(_) => { | ||
| let length = psbt.inputs.len(); | ||
| // Set the UTXO fields, final script_sig and witness | ||
| // and clear everything else. | ||
| let psbt_input = psbt | ||
| .inputs | ||
| .get_mut(n) | ||
| .ok_or(IndexOutOfBoundsError::new(n, length))?; | ||
| let original = mem::take(psbt_input); | ||
| psbt_input.non_witness_utxo = original.non_witness_utxo; | ||
| psbt_input.witness_utxo = original.witness_utxo; | ||
| if !tmp_input.script_sig.is_empty() { | ||
| psbt_input.final_script_sig = Some(tmp_input.script_sig); | ||
| } | ||
| if !tmp_input.witness.is_empty() { | ||
| psbt_input.final_script_witness = Some(tmp_input.witness); | ||
| } | ||
| let final_script_sig = | ||
| (!tmp_input.script_sig.is_empty()).then_some(tmp_input.script_sig); | ||
| let final_script_witness = | ||
| (!tmp_input.witness.is_empty()).then_some(tmp_input.witness); | ||
|
|
||
| // BIP174 finalization clears input metadata except UTXOs, final scripts, | ||
| // and opaque fields the finalizer does not understand. | ||
| *psbt_input = bitcoin::psbt::Input { | ||
| non_witness_utxo: original.non_witness_utxo, | ||
| witness_utxo: original.witness_utxo, | ||
| final_script_sig, | ||
| final_script_witness, | ||
| proprietary: original.proprietary, | ||
| unknown: original.unknown, | ||
| ..Default::default() | ||
| }; | ||
| outcomes.insert(n, FinalizeInputOutcome::Finalized); | ||
| } | ||
| Err(err) => { | ||
| outcomes.insert(n, FinalizeInputOutcome::CouldNotSatisfy(err)); | ||
| } | ||
| Err(_) => finished = false, | ||
| } | ||
| } | ||
| None => finished = false, | ||
| None => { | ||
| outcomes.insert(n, FinalizeInputOutcome::MissingDescriptor); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Clear derivation paths from outputs. | ||
| if finished { | ||
| let finalized = FinalizePsbtOutcome::new(outcomes); | ||
| if clear_output_derivations && finalized.is_finalized() { | ||
| for output in &mut psbt.outputs { | ||
| output.bip32_derivation.clear(); | ||
| output.tap_key_origins.clear(); | ||
| } | ||
| } | ||
|
|
||
| Ok(finished) | ||
| Ok(finalized) | ||
| } | ||
|
|
||
| /// Return the secp256k1 context used for all signing operations. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it make sense to add a Display impl for FinalizeInputOutcome so callers don't have to match on the variants themselves to produce an outcome that can be logged in user interfaces?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe, but I lean toward leaving this out for now. I was thinking of
FinalizeInputOutcomeas structured data that callers can match on and render however they want, especially for UI wording, whileDebugis still available for basic logging. Happy to addDisplayif others think that belongs in the initial API though.