Skip to content
Open
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
122 changes: 114 additions & 8 deletions src/psbt/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,15 @@ pub trait PsbtUtils {
fn get_utxo_for(&self, input_index: usize) -> Option<TxOut>;

/// The total transaction fee amount, sum of input amounts minus sum of output amounts, in sats.
/// If the PSBT is missing a TxOut for an input returns None.
/// Returns `None` if a TxOut is missing for an input, if summing amounts overflows, or if the
/// outputs exceed the inputs.
fn fee_amount(&self) -> Option<Amount>;

/// The transaction's fee rate. This value will only be accurate if calculated AFTER the
/// `Psbt` is finalized and all witness/signature data is added to the
/// transaction.
/// If the PSBT is missing a TxOut for an input returns None.
/// Returns `None` if a TxOut is missing for an input, if summing amounts overflows, if the
/// outputs exceed the inputs, or if the transaction cannot be extracted.
fn fee_rate(&self) -> Option<FeeRate>;
}

Expand All @@ -71,12 +73,18 @@ impl PsbtUtils for Psbt {
let tx = &self.unsigned_tx;
let utxos: Option<Vec<TxOut>> = (0..tx.input.len()).map(|i| self.get_utxo_for(i)).collect();

utxos.map(|inputs| {
let input_amount: Amount = inputs.iter().map(|i| i.value).sum();
let output_amount: Amount = self.unsigned_tx.output.iter().map(|o| o.value).sum();
input_amount
.checked_sub(output_amount)
.expect("input amount must be greater than output amount")
utxos.and_then(|inputs| {
let input_amount = inputs
.iter()
.map(|i| i.value)
.try_fold(Amount::ZERO, Amount::checked_add)?;
let output_amount = self
.unsigned_tx
.output
.iter()
.map(|o| o.value)
.try_fold(Amount::ZERO, Amount::checked_add)?;
input_amount.checked_sub(output_amount)
})
}

Expand Down Expand Up @@ -167,4 +175,102 @@ mod tests {
// Must return None — vout out of bounds, no panic
assert_eq!(psbt.get_utxo_for(0), None);
}

#[test]
fn fee_amount_returns_input_minus_output() {
let prev_tx = build_tx(Amount::from_sat(100_000));
let mut psbt = build_psbt(&prev_tx, 0);
psbt.inputs[0] = Input {
non_witness_utxo: Some(prev_tx),
..Default::default()
};

assert_eq!(psbt.fee_amount(), Some(Amount::from_sat(10_000)));
}

#[test]
fn fee_amount_returns_none_when_outputs_exceed_inputs() {
let prev_tx = build_tx(Amount::from_sat(50_000));
// build_psbt creates a 90_000 sat output
let mut psbt = build_psbt(&prev_tx, 0);
psbt.inputs[0] = Input {
non_witness_utxo: Some(prev_tx),
..Default::default()
};

assert_eq!(psbt.fee_amount(), None);
assert_eq!(psbt.fee_rate(), None);
}

#[test]
fn fee_amount_returns_none_when_input_amounts_overflow() {
let unsigned_tx = Transaction {
version: transaction::Version::TWO,
lock_time: absolute::LockTime::ZERO,
input: vec![
TxIn {
previous_output: OutPoint::null(),
script_sig: ScriptBuf::default(),
sequence: Sequence::MAX,
witness: Witness::default(),
},
TxIn {
previous_output: OutPoint::null(),
script_sig: ScriptBuf::default(),
sequence: Sequence::MAX,
witness: Witness::default(),
},
],
output: vec![TxOut {
value: Amount::from_sat(1_000),
script_pubkey: ScriptBuf::default(),
}],
};
let mut psbt = Psbt::from_unsigned_tx(unsigned_tx).unwrap();
for input in &mut psbt.inputs {
input.witness_utxo = Some(TxOut {
value: Amount::from_sat(u64::MAX),
script_pubkey: ScriptBuf::default(),
});
}

assert_eq!(psbt.fee_amount(), None);
assert_eq!(psbt.fee_rate(), None);
}

#[test]
fn fee_amount_returns_none_when_output_amounts_overflow() {
let prev_tx = build_tx(Amount::from_sat(1_000));
let unsigned_tx = Transaction {
version: transaction::Version::TWO,
lock_time: absolute::LockTime::ZERO,
input: vec![TxIn {
previous_output: OutPoint {
txid: prev_tx.compute_txid(),
vout: 0,
},
script_sig: ScriptBuf::default(),
sequence: Sequence::MAX,
witness: Witness::default(),
}],
output: vec![
TxOut {
value: Amount::from_sat(u64::MAX),
script_pubkey: ScriptBuf::default(),
},
TxOut {
value: Amount::from_sat(u64::MAX),
script_pubkey: ScriptBuf::default(),
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this test, the outputs ([u64::MAX, u64::MAX]) wrap modulo $2^{64}$ to $2^{64} - 2$. Because the input is $1,000$, the wrapped output sum still exceeds the input amount ($2^{64} - 2 &gt; 1,000$).

Consequently, in release builds (or under wrapping arithmetic), this test passes even if the output try_fold checked addition is omitted, because input_amount.checked_sub(output_amount) catches it as outputs > inputs.

To ensure this test specifically isolates and protects the output summation overflow branch, consider choosing output values whose wrapped sum is less than or equal to the input amount (for example, an input of 1_000 sats with outputs u64::MAX - 100 and 200 sats, which wrap to $99 \le 1,000$).

],
};
let mut psbt = Psbt::from_unsigned_tx(unsigned_tx).unwrap();
psbt.inputs[0] = Input {
non_witness_utxo: Some(prev_tx),
..Default::default()
};

assert_eq!(psbt.fee_amount(), None);
assert_eq!(psbt.fee_rate(), None);
}
}