finalizer: BIP174 compliance + sighash/signature-size validation - #79
finalizer: BIP174 compliance + sighash/signature-size validation#79evanlinjin wants to merge 4 commits into
Conversation
**BIP174:** All other data except the UTXO and unknown fields (including PSBT_IN_PROPRIETARY fields the Input Finalizer does not understand) in the input key-value map should be cleared from the PSBT.
Finalization can now fail for reasons miniscript does not model, so `finalize_input` and `FinalizeMap` return a dedicated `FinalizeError` instead of `miniscript::Error`. The finalizer now rejects an input when: - a signature's sighash type disagrees with the declared `PSBT_IN_SIGHASH_TYPE` (mandated by BIP174); - no type is declared yet a signature is neither DEFAULT nor ALL; - a satisfied schnorr witness is larger than the plan committed to (e.g. a 65-byte SIGHASH_ALL sig where 64-byte DEFAULT was planned), which would make the transaction undershoot its target feerate and risk being unbroadcastable. BREAKING CHANGE: `Finalizer::finalize_input`, `FinalizeMap`, and `FinalizeMap::results` now use `FinalizeError` in place of `miniscript::Error`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add tests for the two sighash checks `finalize_input` now performs: `SighashMismatch` (declared PSBT_IN_SIGHASH_TYPE disagrees with the signature) and `SighashNotAllowed` (no type declared, signature is neither DEFAULT nor ALL). `SignatureTooLarge` is left uncovered: it is only reachable in release builds, since in debug miniscript's `satisfy_self` panics on a `debug_assert!` of the signature size before the check is reached. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| if let Placeholder::SchnorrSigPk(_, _, size) | ||
| | Placeholder::SchnorrSigPkHash(_, _, size) = temp | ||
| { | ||
| // Only a witness *larger* than the plan is dangerous. |
There was a problem hiding this comment.
How should a recovery mechanism look like in the case we want to save the extra sats? Should we return an enum here with "Finalized, NotFinalized, Overshoot" or something similar? Should we compare the target fee rate and inner fee rate at the end of the tx creation process and rewind if they don't match?
There was a problem hiding this comment.
Interesting question. I think we should reserve this PR purely for safety. Everything else can be tackled in separate PRs. Could you create a ticket? Thanks!
d7f6d63 to
7129486
Compare
…te/build_psbt API The sighash tests added by the finalizer/selection PRs were authored against the pre-TxTemplate API; update them to TxTemplate::new + build_psbt (which now returns (Psbt, Finalizer)) and the merged test-key const names. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
nymius
left a comment
There was a problem hiding this comment.
This is the only way I found to reproduce the SignatureTooLarge error:
#[test]
#[cfg(not(debug_assertions))]
fn test_finalize_signature_too_large() -> anyhow::Result<()> {
let (non_default_input, input, keymap) = {
let secp = Secp256k1::new();
let (desc, keymap) = Descriptor::parse_descriptor(&secp, TR_XPRV)?;
let def_desc = desc.at_derivation_index(0)?;
let script_pubkey = def_desc.script_pubkey();
let assets = keymap.keys().fold(Assets::new(), |a, k| a.add(k.clone()));
let mut non_default_assets = keymap.keys().fold(Assets::new(), |a, k| a.add(k.clone()));
non_default_assets.keys = std::mem::take(&mut non_default_assets.keys)
.into_iter()
.map(|(key_source, mut can_sign)| {
can_sign.taproot.sighash_default = false;
(key_source, can_sign)
})
.collect();
let plan = def_desc.clone().plan(&assets).expect("failed to create plan");
let non_default_plan = def_desc.plan(&non_default_assets).expect("failed to create plan");
let prev_tx = Transaction {
version: transaction::Version::TWO,
lock_time: absolute::LockTime::ZERO,
input: vec![TxIn::default()],
output: vec![TxOut {
script_pubkey,
value: Amount::from_sat(100_000),
}],
};
let status = crate::ConfirmationStatus::new(1_000, Some(500_000_000))?;
let non_default_input = crate::Input::from_prev_tx(non_default_plan, prev_tx.clone(), 0, Some(status))?;
let input = crate::Input::from_prev_tx(plan, prev_tx, 0, Some(status))?;
(non_default_input, input, keymap)
};
let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000));
let non_default_selection = Selection::new(vec![non_default_input], vec![output.clone()]);
let mut psbt = non_default_selection.create_psbt(PsbtParams::default())?;
psbt.inputs[0].sighash_type = Some(TapSighashType::All.into());
psbt.sign(&Signer(keymap), &Secp256k1::new())
.expect("signing failed");
psbt.inputs[0].sighash_type = None;
let selection = Selection::new(vec![input], vec![output]);
let finalizer = selection.into_finalizer();
let err = finalizer.finalize_input(&mut psbt, 0).unwrap_err();
assert!(matches!(err, FinalizeError::SignatureTooLarge { .. }));
Ok(())
}I run it with:
cargo test --release -- test_finalize_signature_too_largebecause there is a debug_assert in the satisfier that was panicking earlier and preventing the error to happen.
| /// * [`SighashMismatch`] - a signature's sighash type disagrees with the input's declared | ||
| /// `PSBT_IN_SIGHASH_TYPE`. |
There was a problem hiding this comment.
| /// * [`SighashMismatch`] - a signature's sighash type disagrees with the input's declared | |
| /// `PSBT_IN_SIGHASH_TYPE`. | |
| /// * [`SighashMismatch`] - a signature's sighash type disagrees with the input's declared in | |
| /// `PSBT_IN_SIGHASH_TYPE`. |
Description
Hardens
Finalizerto follow BIP174 more closely and refuse to emit a transaction that would be malformed or unsafe to broadcast. The finalizer now validates signatures before assembling the final witness/scriptSig, and reports failures through a dedicatedFinalizeErrorinstead of leakingminiscript::Error.More context here: #74 (comment)
What changed:
unknownandproprietary.SighashMismatch— a signature's sighash type disagrees with the input's declaredPSBT_IN_SIGHASH_TYPE. Mandated by BIP174.SighashNotAllowed— noPSBT_IN_SIGHASH_TYPEis declared, yet a signature uses neitherDEFAULTnorALL. Stricter-than-spec safeguard against silently changing signing semantics.SignatureTooLarge— a satisfied schnorr witness is larger than the size the spendingPlancommitted to (e.g. a 65-byteSIGHASH_ALLsig where 64-byteSIGHASH_DEFAULTwas planned). Because the plan's weight/feerate estimate derives from the committed size, a heavier witness makes the tx undershoot its target feerate and risk being unbroadcastable. Only the larger direction is rejected; a smaller witness merely overpays and is allowed.FinalizeError—finalize_input/FinalizeMapnow returnFinalizeError, modelling the failures above plusSatisfaction(miniscript::Error).Notes to the reviewers
SignatureTooLargeis intentionally not unit-tested: it is only reachable in release builds. In debug, miniscript'ssatisfy_selfhits adebug_assert!on the signature size and panics before our check runs - an upstream bug.Out of scope, for follow-up:
TODOin code). An upstream change could give ECDSA placeholders a committed size to capture low-R grinding fee savings safely.debug_assert→ recoverable-error fix.Changelog notice
Before submitting