Skip to content

Introduce per-input sighash type via Input::set_sighash_type#69

Draft
evanlinjin wants to merge 1 commit into
bitcoindevkit:masterfrom
evanlinjin:feature/per-input-sighash-type
Draft

Introduce per-input sighash type via Input::set_sighash_type#69
evanlinjin wants to merge 1 commit into
bitcoindevkit:masterfrom
evanlinjin:feature/per-input-sighash-type

Conversation

@evanlinjin
Copy link
Copy Markdown
Member

Description

Closes #60

Replace PsbtParams::sighash_type with a per-input setter/getter on Input. Selection::create_psbt propagates each input's sighash type into the resulting PSBT input.

Changelog notice

Added:
- `Input::set_sighash_type` (setter) and `Input::sighash_type` (getter) methods which specifies per-input sighash type.

Removed:
- `PsbtParams::sighash_type` which sets sighash type for all inputs. Replaced by `Input::{set_}sighash_type` for per-input control.

Before submitting

@evanlinjin evanlinjin self-assigned this May 14, 2026
@evanlinjin evanlinjin force-pushed the feature/per-input-sighash-type branch 3 times, most recently from 556f0e1 to 9ffb42c Compare May 15, 2026 20:25
Copy link
Copy Markdown
Contributor

@noahjoeris noahjoeris left a comment

Choose a reason for hiding this comment

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

Nice one!
ACK 9ffb42c

Comment thread src/input.rs
Replace `PsbtParams::sighash_type` with a per-input setter/getter on
`Input`. `Selection::create_psbt` propagates each input's sighash type
into the resulting PSBT input.

`Input::set_sighash_type` accepts anything convertible into
`PsbtSighashType`, including the standard `EcdsaSighashType` and
`TapSighashType` from rust-bitcoin.

BREAKING CHANGE: `PsbtParams::sighash_type` is removed. Callers should
call `Input::set_sighash_type` on each input before selection.
@evanlinjin evanlinjin force-pushed the feature/per-input-sighash-type branch from 9ffb42c to 95d4b35 Compare May 16, 2026 12:55
@evanlinjin evanlinjin requested a review from noahjoeris May 16, 2026 12:56
Copy link
Copy Markdown
Contributor

@noahjoeris noahjoeris left a comment

Choose a reason for hiding this comment

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

ACK 95d4b35

@evanlinjin

This comment was marked as outdated.

@evanlinjin evanlinjin marked this pull request as draft May 17, 2026 06:30
@evanlinjin
Copy link
Copy Markdown
Member Author

Updated design for this PR.

After working through it, the per-Input sighash API turns out to be the source of the problem rather than a feature. Replacing it with a per-PSBT-input policy on PsbtParams.

The problem

For Taproot, signature size depends on sighash: 64 bytes for SIGHASH_DEFAULT, 65 bytes for anything else. Miniscript's Plan bakes this assumption in at construction time (via TaprootCanSign::sighash_default), and Plan::satisfaction_weight() is only accurate if the signer actually uses the assumed sighash.

Input::set_sighash_type introduces sighash state on Input independent of the Plan it was built from, so the two can disagree. The disagreement is silent — weight estimates become wrong, and coin selection drifts off.

Why sighash belongs at the PSBT layer, not on Input

BIP-174 treats sighash as a per-PSBT-input policy:

"The 32-bit unsigned integer specifying the sighash type to be used for this input. Signatures for this input must use the sighash type, finalizers must fail to finalize inputs which have signatures that do not match the specified sighash type."

And when unset:

"If a sighash type is not provided, the signer should sign using SIGHASH_ALL, but may use any sighash type they wish."

One input, one sighash policy, enforced at finalization. Input in bdk-tx describes what is being spent and how it can be satisfied. The sighash policy is a property of the constructed PSBT, not of the spend description — so it belongs in PsbtParams.

Required upstream miniscript change

To make the validation below feasible, we need a small additive accessor on Plan that surfaces the existing TaprootCanSign::sighash_default state:

impl Plan {
    /// For Taproot plans, returns whether the witness was sized assuming `SIGHASH_DEFAULT`.
    /// Returns `None` for non-Taproot plans (signature size is sighash-invariant).
    pub fn tap_sighash_default(&self) -> Option<bool>;
}

Implementation walks self.template and returns based on the size of the first SchnorrSigPk{,Hash} placeholder. Mixed sizes (different keys built with different TaprootCanSign::sighash_default) are pathological and not expressible via a single PSBT sighash field — will either debug-assert uniformity or surface as a third state. Will open the miniscript PR alongside this one.

Proposed design

1. Revert the per-Input sighash API. Drop Input::set_sighash_type and Input::sighash_type. Input carries no sighash state.

2. Add a per-input sighash override on PsbtParams:

pub struct PsbtParams {
    // ... existing fields ...

    /// Per-input sighash overrides, keyed by previous outpoint.
    ///
    /// Applies to Plan-based inputs only. PSBT-based inputs carry their own
    /// `sighash_type` field via their embedded `psbt::Input` and are not affected.
    ///
    /// For each entry, the value must be consistent with the corresponding
    /// Plan's witness-size assumption; `create_psbt` errors on mismatch.
    pub sighash_type: BTreeMap<OutPoint, PsbtSighashType>,
}

3. Centralize all sighash handling in create_psbt. For each Plan-based input, derive the PSBT input's sighash_type field from the override map and the Plan's witness-size assumption (using the upstream accessor above):

params.sighash_type.get(outpoint) plan.tap_sighash_default() Behavior
Some(t) Some(plan_is_default) Validate: t's Taproot interpretation must agree with plan_is_default. Error on mismatch. Else write t.
Some(t) None (ECDSA — sighash-invariant) No constraint. Write t.
None Some(true) (Default-Taproot Plan) Auto-write TapSighashType::Default to lock the signer to 64-byte sigs and match the Plan's weight estimate.
None Some(false) (non-Default-Taproot Plan) Error: Plan expects a 65-byte sig but no non-Default sighash was specified.
None None (ECDSA) Leave the field unset; signer defaults to SIGHASH_ALL.

PSBT-based Inputs (built via Input::from_psbt_input) are unaffected — their embedded psbt::Input already owns its sighash_type field. The map applies only to Plan-based inputs. Stray map entries for outpoints not in the selection are ignored.

Net effect

  • Input is purely a spend description, cannot carry inconsistent sighash state.
  • All sighash logic lives in one place (create_psbt), one rule per case.
  • Default-Taproot Plans get safe auto-lock with zero caller ceremony.
  • Non-Default-Taproot Plans force the caller to declare a specific flag — loud failure, not silent fee drift.
  • ECDSA Plans are unconstrained, matching reality.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PsbtParams::sighash_type does not make sense

2 participants