Skip to main content

breez_sdk_spark/signer/
single_key_signer.rs

1use std::sync::Arc;
2
3use bitcoin::{
4    Witness,
5    ecdsa::Signature,
6    hashes::Hash as _,
7    key::{Secp256k1, TapTweak as _},
8    secp256k1::SecretKey,
9    sighash::{self, SighashCache},
10};
11
12use crate::error::SignerError;
13
14use super::cpfp::CpfpSigner;
15
16/// A CPFP signer backed by a single private key. Signs P2WPKH and P2TR key-path
17/// inputs only; taproot script-path spends are not supported.
18#[cfg_attr(feature = "uniffi", uniffi::export)]
19#[allow(clippy::needless_pass_by_value)]
20pub fn single_key_cpfp_signer(
21    secret_key_bytes: Vec<u8>,
22) -> Result<Arc<dyn CpfpSigner>, SignerError> {
23    Ok(Arc::new(SingleKeySigner::new(secret_key_bytes)?))
24}
25
26pub struct SingleKeySigner {
27    secret_key: SecretKey,
28}
29
30impl SingleKeySigner {
31    #[allow(clippy::needless_pass_by_value)]
32    pub fn new(secret_key_bytes: Vec<u8>) -> Result<Self, SignerError> {
33        let secret_key = SecretKey::from_slice(&secret_key_bytes)
34            .map_err(|e| SignerError::InvalidInput(format!("Invalid secret key: {e}")))?;
35        Ok(Self { secret_key })
36    }
37}
38
39#[macros::async_trait]
40impl CpfpSigner for SingleKeySigner {
41    async fn sign_psbt(&self, psbt_bytes: Vec<u8>) -> Result<Vec<u8>, SignerError> {
42        let mut psbt = bitcoin::Psbt::deserialize(&psbt_bytes)
43            .map_err(|e| SignerError::InvalidInput(format!("Invalid PSBT: {e}")))?;
44
45        let secp = Secp256k1::new();
46        let pubkey = self.secret_key.public_key(&secp);
47        let bitcoin_pubkey = bitcoin::PublicKey::new(pubkey);
48
49        let mut prevouts: Vec<bitcoin::TxOut> = Vec::with_capacity(psbt.inputs.len());
50        let mut has_placeholder_prevout = false;
51        for input in &psbt.inputs {
52            match (&input.witness_utxo, &input.final_script_witness) {
53                (Some(tx_out), _) => prevouts.push(tx_out.clone()),
54                // Not ours to sign; NULL is safe because taproot signing is
55                // refused below whenever a prevout is a placeholder.
56                (None, Some(_)) => {
57                    has_placeholder_prevout = true;
58                    prevouts.push(bitcoin::TxOut::NULL);
59                }
60                (None, None) => {
61                    return Err(SignerError::InvalidInput(
62                        "PSBT input is missing witness_utxo".to_string(),
63                    ));
64                }
65            }
66        }
67
68        let mut cache = SighashCache::new(&psbt.unsigned_tx);
69        let mut ecdsa_signatures = vec![];
70        let mut taproot_indices = vec![];
71
72        for (i, input) in psbt.inputs.iter().enumerate() {
73            if input.final_script_witness.is_some() {
74                continue;
75            }
76            let Some(tx_out) = &input.witness_utxo else {
77                return Err(SignerError::InvalidInput(
78                    "PSBT input is missing witness_utxo".to_string(),
79                ));
80            };
81
82            if tx_out.script_pubkey.is_p2tr() {
83                taproot_indices.push(i);
84            } else if tx_out.script_pubkey.is_p2wpkh() {
85                let (msg, ecdsa_type) = psbt
86                    .sighash_ecdsa(i, &mut cache)
87                    .map_err(|e| SignerError::Signing(format!("ECDSA sighash error: {e}")))?;
88                let sig = secp.sign_ecdsa(&msg, &self.secret_key);
89                ecdsa_signatures.push((
90                    i,
91                    bitcoin_pubkey,
92                    Signature {
93                        signature: sig,
94                        sighash_type: ecdsa_type,
95                    },
96                ));
97            } else {
98                return Err(SignerError::InvalidInput(
99                    "unsupported script type for the built-in single-key signer \
100                     (only P2WPKH and P2TR key-path)"
101                        .to_string(),
102                ));
103            }
104        }
105
106        for (i, pk, signature) in ecdsa_signatures {
107            let mut witness = Witness::new();
108            witness.push(signature.to_vec());
109            witness.push(pk.to_bytes());
110            psbt.inputs[i].final_script_witness = Some(witness);
111            psbt.inputs[i].partial_sigs.clear();
112        }
113
114        if !taproot_indices.is_empty() {
115            if has_placeholder_prevout {
116                return Err(SignerError::InvalidInput(
117                    "cannot sign taproot input: another PSBT input is missing witness_utxo"
118                        .to_string(),
119                ));
120            }
121            let keypair = bitcoin::key::Keypair::from_secret_key(&secp, &self.secret_key)
122                .tap_tweak(&secp, None)
123                .to_keypair();
124            let prevouts_ref = sighash::Prevouts::All(&prevouts);
125            for i in taproot_indices {
126                let sighash = cache
127                    .taproot_key_spend_signature_hash(
128                        i,
129                        &prevouts_ref,
130                        sighash::TapSighashType::Default,
131                    )
132                    .map_err(|e| SignerError::Signing(format!("Taproot sighash error: {e}")))?;
133                let msg = bitcoin::secp256k1::Message::from_digest(sighash.to_byte_array());
134                let schnorr_sig = secp.sign_schnorr_no_aux_rand(&msg, &keypair);
135                let tap_sig = bitcoin::taproot::Signature {
136                    signature: schnorr_sig,
137                    sighash_type: sighash::TapSighashType::Default,
138                };
139                let mut witness = Witness::new();
140                witness.push(tap_sig.to_vec());
141                psbt.inputs[i].final_script_witness = Some(witness);
142                psbt.inputs[i].tap_key_sig = None;
143            }
144        }
145
146        Ok(psbt.serialize())
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use bitcoin::{
154        Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness,
155        absolute::LockTime, transaction::Version,
156    };
157
158    fn test_secret_key_bytes() -> Vec<u8> {
159        vec![1u8; 32]
160    }
161
162    fn signer_p2wpkh_script() -> ScriptBuf {
163        let secp = Secp256k1::new();
164        let sk = SecretKey::from_slice(&test_secret_key_bytes()).unwrap();
165        let cpk = bitcoin::CompressedPublicKey(sk.public_key(&secp));
166        ScriptBuf::new_p2wpkh(&cpk.wpubkey_hash())
167    }
168
169    fn dummy_txin(vout: u32) -> TxIn {
170        TxIn {
171            previous_output: OutPoint {
172                txid: Txid::all_zeros(),
173                vout,
174            },
175            script_sig: ScriptBuf::new(),
176            sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
177            witness: Witness::new(),
178        }
179    }
180
181    fn empty_psbt(n: u32) -> bitcoin::Psbt {
182        let tx = Transaction {
183            version: Version::TWO,
184            lock_time: LockTime::ZERO,
185            input: (0..n).map(dummy_txin).collect(),
186            output: vec![TxOut {
187                value: Amount::from_sat(1_000),
188                script_pubkey: signer_p2wpkh_script(),
189            }],
190        };
191        bitcoin::Psbt::from_unsigned_tx(tx).unwrap()
192    }
193
194    fn set_finalized_anchor(input: &mut bitcoin::psbt::Input) {
195        input.witness_utxo = Some(TxOut {
196            value: Amount::ZERO,
197            script_pubkey: ScriptBuf::from(vec![0x51, 0x02, 0x4e, 0x73]),
198        });
199        input.final_script_witness = Some(Witness::new());
200    }
201
202    #[macros::async_test_all]
203    async fn single_key_missing_witness_utxo_errors() {
204        let psbt = empty_psbt(1);
205        let signer = SingleKeySigner::new(test_secret_key_bytes()).unwrap();
206        let res = signer.sign_psbt(psbt.serialize()).await;
207        assert!(res.is_err(), "expected error for missing witness_utxo");
208    }
209
210    #[macros::async_test_all]
211    async fn single_key_unsupported_script_type_errors() {
212        let mut psbt = empty_psbt(1);
213        let p2wsh = ScriptBuf::new_p2wsh(&bitcoin::WScriptHash::from_byte_array([2u8; 32]));
214        psbt.inputs[0].witness_utxo = Some(TxOut {
215            value: Amount::from_sat(5_000),
216            script_pubkey: p2wsh,
217        });
218        let signer = SingleKeySigner::new(test_secret_key_bytes()).unwrap();
219        let res = signer.sign_psbt(psbt.serialize()).await;
220        assert!(res.is_err(), "expected error for unsupported script type");
221    }
222
223    #[macros::async_test_all]
224    async fn single_key_signs_p2wpkh_and_skips_anchor() {
225        let mut psbt = empty_psbt(2);
226        psbt.inputs[0].witness_utxo = Some(TxOut {
227            value: Amount::from_sat(10_000),
228            script_pubkey: signer_p2wpkh_script(),
229        });
230        set_finalized_anchor(&mut psbt.inputs[1]);
231
232        let signer = SingleKeySigner::new(test_secret_key_bytes()).unwrap();
233        let signed_bytes = signer.sign_psbt(psbt.serialize()).await.unwrap();
234        let out_psbt = bitcoin::Psbt::deserialize(&signed_bytes).unwrap();
235
236        let funding_witness = out_psbt.inputs[0]
237            .final_script_witness
238            .as_ref()
239            .expect("funding input should be finalized");
240        assert_eq!(funding_witness.len(), 2, "P2WPKH witness is [sig, pubkey]");
241        assert!(out_psbt.inputs[1].final_script_witness.is_some());
242    }
243}