silent_payments_core/input.rs
1//! Transaction input classification and public key extraction for BIP 352.
2//!
3//! Wraps `bdk_sp::tag_txin` and `bdk_sp::receive::extract_pubkey` with
4//! type-safe error handling. Ineligible inputs ALWAYS produce an explicit
5//! [`InputError`] -- they are never silently filtered (SEC-03).
6//!
7//! # Input types
8//!
9//! BIP 352 recognizes four eligible input types:
10//! - **P2TR** -- Taproot key-path spend (excludes NUMS internal key)
11//! - **P2WPKH** -- Native SegWit v0
12//! - **P2SH-P2WPKH** -- Nested SegWit (P2WPKH inside P2SH)
13//! - **P2PKH** -- Legacy pay-to-pubkey-hash
14//!
15//! Everything else (SegWit v2+, P2SH multisig, P2PK, NUMS-point P2TR
16//! script-path spends) is ineligible and produces [`InputError::IneligibleInput`].
17
18use bitcoin::secp256k1::PublicKey;
19use bitcoin::{ScriptBuf, TxIn, TxOut};
20
21use crate::error::InputError;
22
23/// The type of a transaction input eligible for Silent Payments.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25#[allow(non_camel_case_types)]
26pub enum SpInputType {
27 /// Taproot key-path spend (P2TR).
28 P2TR,
29 /// Native SegWit v0 (P2WPKH).
30 P2WPKH,
31 /// Nested SegWit (P2SH wrapping P2WPKH).
32 P2SH_P2WPKH,
33 /// Legacy pay-to-pubkey-hash (P2PKH).
34 P2PKH,
35}
36
37impl std::fmt::Display for SpInputType {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 match self {
40 Self::P2TR => f.write_str("P2TR"),
41 Self::P2WPKH => f.write_str("P2WPKH"),
42 Self::P2SH_P2WPKH => f.write_str("P2SH-P2WPKH"),
43 Self::P2PKH => f.write_str("P2PKH"),
44 }
45 }
46}
47
48/// A classified transaction input with its extracted public key.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct ClassifiedInput {
51 /// The BIP 352 input type.
52 pub input_type: SpInputType,
53 /// The public key extracted from the input (used for ECDH).
54 pub public_key: PublicKey,
55}
56
57/// An input that was skipped during batch classification, with the reason.
58#[derive(Debug, Clone)]
59pub struct SkippedInput {
60 /// Zero-based index of the input in the transaction.
61 pub index: usize,
62 /// The error describing why the input was skipped.
63 pub error: InputError,
64}
65
66/// The result of batch input classification.
67#[derive(Debug)]
68pub struct ClassificationResult {
69 /// Inputs that were successfully classified as eligible.
70 pub eligible: Vec<ClassifiedInput>,
71 /// Inputs that were skipped with explicit reasons (SEC-03).
72 pub skipped: Vec<SkippedInput>,
73}
74
75/// Map `bdk_sp::SpInputs` to our [`SpInputType`].
76fn map_input_type(sp: bdk_sp::SpInputs) -> SpInputType {
77 match sp {
78 bdk_sp::SpInputs::Tr => SpInputType::P2TR,
79 bdk_sp::SpInputs::Wpkh => SpInputType::P2WPKH,
80 bdk_sp::SpInputs::ShWpkh => SpInputType::P2SH_P2WPKH,
81 bdk_sp::SpInputs::Pkh => SpInputType::P2PKH,
82 }
83}
84
85/// Infer a human-readable script type description from the previous output script.
86///
87/// Used to provide meaningful error messages for ineligible inputs.
88fn infer_script_type(script_pubkey: &ScriptBuf) -> String {
89 if script_pubkey.is_p2tr() {
90 "P2TR (script-path/NUMS)".to_string()
91 } else if script_pubkey.is_p2wpkh() {
92 "P2WPKH".to_string()
93 } else if script_pubkey.is_p2sh() {
94 "P2SH".to_string()
95 } else if script_pubkey.is_p2pkh() {
96 "P2PKH".to_string()
97 } else if script_pubkey.is_p2wsh() {
98 "P2WSH".to_string()
99 } else if script_pubkey.is_witness_program() {
100 "SegWit (unknown version)".to_string()
101 } else {
102 "unknown".to_string()
103 }
104}
105
106/// Classify a single transaction input and extract its public key.
107///
108/// Delegates to `bdk_sp::tag_txin` for classification and
109/// `bdk_sp::receive::extract_pubkey` for key extraction. If the input
110/// is ineligible (e.g., SegWit v2+, NUMS-point P2TR, multisig),
111/// returns [`InputError::IneligibleInput`] with a descriptive reason.
112///
113/// # Arguments
114///
115/// * `txin` - The transaction input to classify.
116/// * `prev_script_pubkey` - The scriptPubKey of the previous output being spent.
117///
118/// # Errors
119///
120/// - [`InputError::IneligibleInput`] -- input type not supported by BIP 352.
121/// - [`InputError::PubKeyExtraction`] -- eligible type but key extraction failed.
122pub fn classify_input(
123 txin: &TxIn,
124 prev_script_pubkey: &ScriptBuf,
125) -> Result<ClassifiedInput, InputError> {
126 // Step 1: Tag the input type via bdk-sp.
127 let sp_tag = bdk_sp::tag_txin(txin, prev_script_pubkey).ok_or_else(|| {
128 let script_type = infer_script_type(prev_script_pubkey);
129 InputError::IneligibleInput {
130 script_type,
131 reason: "input type not eligible for BIP 352 Silent Payments".to_string(),
132 }
133 })?;
134
135 let input_type = map_input_type(sp_tag);
136
137 // Step 2: Extract the public key via bdk-sp.
138 // extract_pubkey takes ownership of TxIn, so we clone.
139 let (_, pubkey) = bdk_sp::receive::extract_pubkey(txin.clone(), prev_script_pubkey)
140 .ok_or_else(|| InputError::PubKeyExtraction {
141 input_type: input_type.to_string(),
142 reason: "bdk-sp could not extract a valid compressed public key".to_string(),
143 })?;
144
145 Ok(ClassifiedInput {
146 input_type,
147 public_key: pubkey,
148 })
149}
150
151/// Classify all inputs in a transaction, reporting both eligible and skipped inputs.
152///
153/// SEC-03: Nothing is silently filtered. Every ineligible input appears in
154/// `ClassificationResult::skipped` with an explicit reason.
155///
156/// # Arguments
157///
158/// * `inputs` - The transaction inputs.
159/// * `prev_outputs` - The previous outputs being spent (must match inputs 1:1).
160///
161/// # Errors
162///
163/// Returns [`InputError::NoEligibleInputs`] if zero inputs are classified
164/// as eligible (and none produced extraction errors).
165pub fn collect_eligible_inputs(
166 inputs: &[TxIn],
167 prev_outputs: &[TxOut],
168) -> Result<ClassificationResult, InputError> {
169 let mut eligible = Vec::new();
170 let mut skipped = Vec::new();
171
172 for (index, (txin, prev_out)) in inputs.iter().zip(prev_outputs.iter()).enumerate() {
173 match classify_input(txin, &prev_out.script_pubkey) {
174 Ok(classified) => eligible.push(classified),
175 Err(err) => skipped.push(SkippedInput { index, error: err }),
176 }
177 }
178
179 if eligible.is_empty() {
180 return Err(InputError::NoEligibleInputs);
181 }
182
183 Ok(ClassificationResult { eligible, skipped })
184}