Skip to main content

silent_payments_psbt/
roles.rs

1//! BIP 375 PSBT role typestate machine.
2//!
3//! Four distinct types enforce role ordering at compile time:
4//! [`SpPsbtConstructor`] -> [`SpPsbtUpdater`] -> [`SpPsbtSigner`] -> [`SpPsbtExtractor`]
5//!
6//! Each transition consumes `self` and returns the next type, preventing
7//! skipping or reordering roles. The compiler enforces correctness.
8
9use bitcoin::psbt::Psbt;
10use bitcoin::secp256k1::PublicKey;
11
12use silent_payments_core::keys::{ScanPublicKey, SpendPublicKey};
13
14use crate::dleq::DleqProof;
15use crate::error::{DleqError, PsbtError};
16use crate::fields;
17
18// ---------------------------------------------------------------------------
19// Constructor
20// ---------------------------------------------------------------------------
21
22/// BIP 375 Constructor role: adds SP output info and labels to the PSBT.
23///
24/// Created from an unsigned PSBT via [`SpPsbtConstructor::new`]. The constructor
25/// allows adding Silent Payment recipient info (scan + spend keys) and optional
26/// labels to PSBT outputs. Transitions to [`SpPsbtUpdater`] after at least one
27/// SP output has been added.
28pub struct SpPsbtConstructor {
29    inner: Psbt,
30}
31
32impl SpPsbtConstructor {
33    /// Wrap a PSBT for Silent Payment construction.
34    ///
35    /// No validation is performed at this stage -- the PSBT may have zero outputs
36    /// initially (outputs can be added by the wallet before calling `add_sp_output`).
37    pub fn new(psbt: Psbt) -> Result<Self, PsbtError> {
38        Ok(Self { inner: psbt })
39    }
40
41    /// Add Silent Payment recipient info to a PSBT output.
42    ///
43    /// Stores the scan and spend public keys as `PSBT_OUT_SP_V0_INFO` in the
44    /// output's unknown map.
45    ///
46    /// # Errors
47    ///
48    /// Returns [`PsbtError::OutputIndexOutOfBounds`] if `output_index` exceeds
49    /// the number of PSBT outputs.
50    pub fn add_sp_output(
51        mut self,
52        output_index: usize,
53        scan_pubkey: &ScanPublicKey,
54        spend_pubkey: &SpendPublicKey,
55    ) -> Result<Self, PsbtError> {
56        if output_index >= self.inner.outputs.len() {
57            return Err(PsbtError::OutputIndexOutOfBounds {
58                index: output_index,
59                count: self.inner.outputs.len(),
60            });
61        }
62
63        fields::set_sp_output_info(
64            &mut self.inner.outputs[output_index],
65            scan_pubkey.as_inner(),
66            spend_pubkey.as_inner(),
67        );
68
69        Ok(self)
70    }
71
72    /// Add an optional label to a PSBT output.
73    ///
74    /// Stores the label as `PSBT_OUT_SP_V0_LABEL` in the output's unknown map.
75    ///
76    /// # Errors
77    ///
78    /// Returns [`PsbtError::OutputIndexOutOfBounds`] if `output_index` exceeds
79    /// the number of PSBT outputs.
80    pub fn add_sp_label(mut self, output_index: usize, label: u32) -> Result<Self, PsbtError> {
81        if output_index >= self.inner.outputs.len() {
82            return Err(PsbtError::OutputIndexOutOfBounds {
83                index: output_index,
84                count: self.inner.outputs.len(),
85            });
86        }
87
88        fields::set_sp_output_label(&mut self.inner.outputs[output_index], label);
89
90        Ok(self)
91    }
92
93    /// Transition to the Updater role.
94    ///
95    /// Validates that at least one PSBT output has SP_V0_INFO fields.
96    ///
97    /// # Errors
98    ///
99    /// Returns [`PsbtError::NoSpOutputs`] if no output has SP info.
100    pub fn into_updater(self) -> Result<SpPsbtUpdater, PsbtError> {
101        let has_sp_output = self
102            .inner
103            .outputs
104            .iter()
105            .any(|output| fields::get_sp_output_info(output).is_some());
106
107        if !has_sp_output {
108            return Err(PsbtError::NoSpOutputs);
109        }
110
111        Ok(SpPsbtUpdater { inner: self.inner })
112    }
113}
114
115// ---------------------------------------------------------------------------
116// Updater
117// ---------------------------------------------------------------------------
118
119/// BIP 375 Updater role: allows wallet-specific BIP32 operations on the PSBT.
120///
121/// This is a passthrough role. The wallet can add BIP32 derivation paths,
122/// witness UTXOs, and other standard PSBT data via [`inner_mut`](SpPsbtUpdater::inner_mut).
123/// Transitions to [`SpPsbtSigner`] without additional validation.
124pub struct SpPsbtUpdater {
125    inner: Psbt,
126}
127
128impl SpPsbtUpdater {
129    /// Read access to the inner PSBT for wallet inspection.
130    pub fn inner(&self) -> &Psbt {
131        &self.inner
132    }
133
134    /// Write access to the inner PSBT for wallet modifications.
135    ///
136    /// Wallets use this to add BIP32 derivation paths, witness UTXOs,
137    /// and other standard PSBT data before signing.
138    pub fn inner_mut(&mut self) -> &mut Psbt {
139        &mut self.inner
140    }
141
142    /// Transition to the Signer role.
143    ///
144    /// No validation is required -- BIP32 paths are optional.
145    pub fn into_signer(self) -> SpPsbtSigner {
146        SpPsbtSigner { inner: self.inner }
147    }
148}
149
150// ---------------------------------------------------------------------------
151// Signer
152// ---------------------------------------------------------------------------
153
154/// BIP 375 Signer role: adds ECDH shares with mandatory DLEQ proof validation.
155///
156/// Every call to [`add_ecdh_share`](SpPsbtSigner::add_ecdh_share) or
157/// [`add_global_ecdh_share`](SpPsbtSigner::add_global_ecdh_share) verifies
158/// the accompanying DLEQ proof before storing the share. Mixing global and
159/// per-input shares for the same scan key is rejected.
160pub struct SpPsbtSigner {
161    inner: Psbt,
162}
163
164impl SpPsbtSigner {
165    /// Add a per-input ECDH share with mandatory DLEQ proof verification.
166    ///
167    /// The DLEQ proof is verified before storing the share. If a global ECDH share
168    /// already exists for this scan key, returns [`PsbtError::DuplicateShareType`].
169    ///
170    /// # Arguments
171    ///
172    /// * `input_index` - The PSBT input index to store the share on
173    /// * `scan_pubkey` - The recipient's scan public key
174    /// * `ecdh_share` - The signer's ECDH share (a_i * B_scan)
175    /// * `dleq_proof` - The DLEQ proof for this share
176    /// * `signer_pubkey` - The signer's public key (A = a * G)
177    /// * `secp` - Secp256k1 verification context
178    pub fn add_ecdh_share(
179        mut self,
180        input_index: usize,
181        scan_pubkey: &ScanPublicKey,
182        ecdh_share: &PublicKey,
183        dleq_proof: &DleqProof,
184        signer_pubkey: &PublicKey,
185        secp: &bitcoin::secp256k1::Secp256k1<impl bitcoin::secp256k1::Verification>,
186    ) -> Result<Self, PsbtError> {
187        // Validate input index
188        if input_index >= self.inner.inputs.len() {
189            return Err(PsbtError::OutputIndexOutOfBounds {
190                index: input_index,
191                count: self.inner.inputs.len(),
192            });
193        }
194
195        // Check for existing global share for this scan key (reject mixing)
196        if fields::get_global_ecdh_share(&self.inner, scan_pubkey.as_inner()).is_some() {
197            return Err(PsbtError::DuplicateShareType {
198                scan_key: hex_encode_pubkey(scan_pubkey.as_inner()),
199            });
200        }
201
202        // Verify DLEQ proof (mandatory -- no unchecked paths)
203        let valid = dleq_proof.verify(signer_pubkey, scan_pubkey.as_inner(), ecdh_share, secp)?;
204        if !valid {
205            return Err(PsbtError::InvalidProof(DleqError::VerificationFailed));
206        }
207
208        // Store per-input ECDH share and DLEQ proof
209        fields::set_input_ecdh_share(
210            &mut self.inner.inputs[input_index],
211            scan_pubkey.as_inner(),
212            ecdh_share,
213        );
214        fields::set_input_dleq_proof(
215            &mut self.inner.inputs[input_index],
216            scan_pubkey.as_inner(),
217            dleq_proof.as_bytes(),
218        );
219
220        Ok(self)
221    }
222
223    /// Add a global ECDH share with mandatory DLEQ proof verification.
224    ///
225    /// Used for single-signer workflows where the signer contributes a single
226    /// aggregated share. The DLEQ proof is verified before storing.
227    ///
228    /// If per-input ECDH shares already exist for this scan key across any input,
229    /// returns [`PsbtError::DuplicateShareType`].
230    pub fn add_global_ecdh_share(
231        mut self,
232        scan_pubkey: &ScanPublicKey,
233        ecdh_share: &PublicKey,
234        dleq_proof: &DleqProof,
235        signer_pubkey: &PublicKey,
236        secp: &bitcoin::secp256k1::Secp256k1<impl bitcoin::secp256k1::Verification>,
237    ) -> Result<Self, PsbtError> {
238        // Check for existing per-input shares for this scan key (reject mixing)
239        let has_per_input = self
240            .inner
241            .inputs
242            .iter()
243            .any(|input| fields::get_input_ecdh_share(input, scan_pubkey.as_inner()).is_some());
244
245        if has_per_input {
246            return Err(PsbtError::DuplicateShareType {
247                scan_key: hex_encode_pubkey(scan_pubkey.as_inner()),
248            });
249        }
250
251        // Verify DLEQ proof (mandatory -- no unchecked paths)
252        let valid = dleq_proof.verify(signer_pubkey, scan_pubkey.as_inner(), ecdh_share, secp)?;
253        if !valid {
254            return Err(PsbtError::InvalidProof(DleqError::VerificationFailed));
255        }
256
257        // Store global ECDH share and DLEQ proof
258        fields::set_global_ecdh_share(&mut self.inner, scan_pubkey.as_inner(), ecdh_share);
259        fields::set_global_dleq_proof(
260            &mut self.inner,
261            scan_pubkey.as_inner(),
262            dleq_proof.as_bytes(),
263        );
264
265        Ok(self)
266    }
267
268    /// Transition to the Extractor role.
269    ///
270    /// Validates that every unique scan key referenced in SP output info fields
271    /// has either a global ECDH share or per-input shares on all inputs.
272    ///
273    /// # Errors
274    ///
275    /// Returns [`PsbtError::IncompleteSigning`] if any scan key lacks shares.
276    pub fn into_extractor(self) -> Result<SpPsbtExtractor, PsbtError> {
277        // Collect all unique scan pubkeys from SP output info fields
278        let mut scan_keys: Vec<PublicKey> = Vec::new();
279        for output in &self.inner.outputs {
280            if let Some((scan_key, _spend_key)) = fields::get_sp_output_info(output) {
281                if !scan_keys.contains(&scan_key) {
282                    scan_keys.push(scan_key);
283                }
284            }
285        }
286
287        // For each scan key: check if global share OR all inputs have per-input shares
288        for scan_key in &scan_keys {
289            let has_global = fields::get_global_ecdh_share(&self.inner, scan_key).is_some();
290
291            if has_global {
292                continue;
293            }
294
295            // Check all inputs have per-input shares
296            let all_inputs_have_shares = self
297                .inner
298                .inputs
299                .iter()
300                .all(|input| fields::get_input_ecdh_share(input, scan_key).is_some());
301
302            if !all_inputs_have_shares {
303                return Err(PsbtError::IncompleteSigning);
304            }
305        }
306
307        Ok(SpPsbtExtractor { inner: self.inner })
308    }
309}
310
311/// Helper: hex-encode a public key for error messages.
312fn hex_encode_pubkey(key: &PublicKey) -> String {
313    key.serialize().iter().map(|b| format!("{b:02x}")).collect()
314}
315
316/// BIP 375 Extractor role: aggregates ECDH shares and computes SP output scripts.
317///
318/// Created via [`SpPsbtSigner::into_extractor`] after all required ECDH shares
319/// are present. The [`extract`](SpPsbtExtractor::extract) method computes
320/// the final SP output scripts and returns the updated PSBT.
321pub struct SpPsbtExtractor {
322    inner: Psbt,
323}
324
325impl SpPsbtExtractor {
326    /// Compute SP output scripts from aggregated ECDH shares.
327    ///
328    /// For each unique scan key in SP output info fields:
329    /// 1. Aggregates ECDH shares (global share directly, or per-input via `combine_keys`)
330    /// 2. The aggregated share IS the BIP 352 shared secret point
331    /// 3. Derives output pubkeys via `compute_output_pubkey(spend_key, shared_secret, k)`
332    /// 4. Builds P2TR scripts from the output pubkeys
333    ///
334    /// Returns the updated PSBT (with computed scriptPubKeys) and extracted output metadata.
335    pub fn extract(
336        mut self,
337        _secp: &bitcoin::secp256k1::Secp256k1<impl bitcoin::secp256k1::Verification>,
338    ) -> Result<(Psbt, Vec<crate::output::ExtractedSpOutput>), PsbtError> {
339        // Collect SP output info: (output_index, scan_key, spend_key)
340        let sp_outputs: Vec<(usize, PublicKey, PublicKey)> = self
341            .inner
342            .outputs
343            .iter()
344            .enumerate()
345            .filter_map(|(idx, output)| {
346                fields::get_sp_output_info(output).map(|(scan, spend)| (idx, scan, spend))
347            })
348            .collect();
349
350        if sp_outputs.is_empty() {
351            return Err(PsbtError::NoSpOutputs);
352        }
353
354        // Group outputs by scan_key, preserving order for k-counter
355        let mut groups: Vec<(PublicKey, Vec<(usize, PublicKey)>)> = Vec::new();
356        for (idx, scan_key, spend_key) in &sp_outputs {
357            if let Some(group) = groups.iter_mut().find(|(sk, _)| sk == scan_key) {
358                group.1.push((*idx, *spend_key));
359            } else {
360                groups.push((*scan_key, vec![(*idx, *spend_key)]));
361            }
362        }
363
364        let mut extracted: Vec<crate::output::ExtractedSpOutput> = Vec::new();
365
366        for (scan_key, outputs_for_key) in &groups {
367            // Aggregate ECDH share for this scan key
368            let shared_secret = self.aggregate_ecdh_share(scan_key)?;
369
370            // Derive output pubkeys for each output with this scan key
371            for (k, (output_index, spend_key)) in outputs_for_key.iter().enumerate() {
372                let output_pubkey = silent_payments_core::crypto::compute_output_pubkey(
373                    &SpendPublicKey::from(*spend_key),
374                    &shared_secret,
375                    k as u32,
376                )?;
377
378                // Build P2TR script from the output pubkey's x-only key
379                let (x_only, _parity) = output_pubkey.x_only_public_key();
380                let tweaked = bitcoin::key::TweakedPublicKey::dangerous_assume_tweaked(x_only);
381                let script = bitcoin::ScriptBuf::new_p2tr_tweaked(tweaked);
382
383                // Update the PSBT output's scriptPubKey
384                self.inner.unsigned_tx.output[*output_index].script_pubkey = script.clone();
385
386                extracted.push(crate::output::ExtractedSpOutput::new(
387                    script,
388                    x_only,
389                    *output_index,
390                    *scan_key,
391                ));
392            }
393        }
394
395        Ok((self.inner, extracted))
396    }
397
398    /// Aggregate ECDH shares for a scan key.
399    ///
400    /// If a global share exists, returns it directly.
401    /// Otherwise, aggregates per-input shares via `PublicKey::combine_keys`.
402    fn aggregate_ecdh_share(&self, scan_key: &PublicKey) -> Result<PublicKey, PsbtError> {
403        // Check for global share first
404        if let Some(global_share) = fields::get_global_ecdh_share(&self.inner, scan_key) {
405            return Ok(global_share);
406        }
407
408        // Collect per-input shares
409        let shares: Vec<PublicKey> = self
410            .inner
411            .inputs
412            .iter()
413            .filter_map(|input| fields::get_input_ecdh_share(input, scan_key))
414            .collect();
415
416        if shares.is_empty() {
417            return Err(PsbtError::MissingEcdhShare {
418                scan_key: hex_encode_pubkey(scan_key),
419            });
420        }
421
422        // Aggregate via combine_keys (sum of points)
423        let refs: Vec<&PublicKey> = shares.iter().collect();
424        PublicKey::combine_keys(&refs).map_err(|e| {
425            PsbtError::Crypto(silent_payments_core::CryptoError::Secp256k1(e.to_string()))
426        })
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use bitcoin::absolute::LockTime;
434    use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
435    use bitcoin::transaction::{Transaction, Version};
436    use bitcoin::{Amount, ScriptBuf, TxIn, TxOut};
437
438    /// Helper: create a test public key from a scalar byte.
439    fn test_pubkey(byte: u8) -> PublicKey {
440        let secp = Secp256k1::new();
441        let mut key_bytes = [0u8; 32];
442        key_bytes[31] = byte;
443        let sk = SecretKey::from_slice(&key_bytes).unwrap();
444        sk.public_key(&secp)
445    }
446
447    /// Helper: create a test secret key from a scalar byte.
448    #[cfg(feature = "experimental-dleq")]
449    fn test_secret_key(byte: u8) -> SecretKey {
450        let mut key_bytes = [0u8; 32];
451        key_bytes[31] = byte;
452        SecretKey::from_slice(&key_bytes).unwrap()
453    }
454
455    /// Helper: create a minimal PSBT with the given number of inputs and outputs.
456    fn test_psbt(n_inputs: usize, n_outputs: usize) -> Psbt {
457        let tx = Transaction {
458            version: Version::TWO,
459            lock_time: LockTime::ZERO,
460            input: (0..n_inputs).map(|_| TxIn::default()).collect(),
461            output: (0..n_outputs)
462                .map(|_| TxOut {
463                    value: Amount::from_sat(50_000),
464                    script_pubkey: ScriptBuf::new(),
465                })
466                .collect(),
467        };
468        Psbt::from_unsigned_tx(tx).unwrap()
469    }
470
471    /// Helper: create a minimal PSBT with 1 input and the given number of outputs.
472    fn test_psbt_with_outputs(n_outputs: usize) -> Psbt {
473        test_psbt(1, n_outputs)
474    }
475
476    /// Helper: build a signer from a PSBT with SP output info already set.
477    fn build_signer(
478        n_inputs: usize,
479        n_outputs: usize,
480        scan_byte: u8,
481        spend_byte: u8,
482    ) -> SpPsbtSigner {
483        let psbt = test_psbt(n_inputs, n_outputs);
484        let scan = ScanPublicKey::from(test_pubkey(scan_byte));
485        let spend = SpendPublicKey::from(test_pubkey(spend_byte));
486
487        let constructor = SpPsbtConstructor::new(psbt).unwrap();
488        let constructor = constructor.add_sp_output(0, &scan, &spend).unwrap();
489        let updater = constructor.into_updater().unwrap();
490        updater.into_signer()
491    }
492
493    // --- Constructor tests ---
494
495    #[test]
496    fn test_constructor_add_sp_output() {
497        let psbt = test_psbt_with_outputs(1);
498        let scan = ScanPublicKey::from(test_pubkey(1));
499        let spend = SpendPublicKey::from(test_pubkey(2));
500
501        let constructor = SpPsbtConstructor::new(psbt).unwrap();
502        let constructor = constructor.add_sp_output(0, &scan, &spend).unwrap();
503
504        // Transition to updater so we can verify via inner()
505        let updater = constructor.into_updater().unwrap();
506        let (got_scan, got_spend) =
507            fields::get_sp_output_info(&updater.inner().outputs[0]).expect("should read back");
508        assert_eq!(got_scan, *scan.as_inner());
509        assert_eq!(got_spend, *spend.as_inner());
510    }
511
512    #[test]
513    fn test_constructor_output_index_out_of_bounds() {
514        let psbt = test_psbt_with_outputs(1);
515        let scan = ScanPublicKey::from(test_pubkey(1));
516        let spend = SpendPublicKey::from(test_pubkey(2));
517
518        let constructor = SpPsbtConstructor::new(psbt).unwrap();
519        let result = constructor.add_sp_output(5, &scan, &spend);
520
521        assert!(
522            matches!(
523                result,
524                Err(PsbtError::OutputIndexOutOfBounds { index: 5, count: 1 })
525            ),
526            "should reject out-of-bounds index, got: {:?}",
527            result.err()
528        );
529    }
530
531    #[test]
532    fn test_constructor_into_updater_requires_sp_output() {
533        let psbt = test_psbt_with_outputs(1);
534        let constructor = SpPsbtConstructor::new(psbt).unwrap();
535
536        let result = constructor.into_updater();
537        assert!(
538            matches!(result, Err(PsbtError::NoSpOutputs)),
539            "should require at least one SP output, got: {:?}",
540            result.err()
541        );
542    }
543
544    #[test]
545    fn test_constructor_into_updater_succeeds() {
546        let psbt = test_psbt_with_outputs(1);
547        let scan = ScanPublicKey::from(test_pubkey(1));
548        let spend = SpendPublicKey::from(test_pubkey(2));
549
550        let constructor = SpPsbtConstructor::new(psbt).unwrap();
551        let constructor = constructor.add_sp_output(0, &scan, &spend).unwrap();
552
553        let updater = constructor.into_updater();
554        assert!(updater.is_ok(), "should transition after adding SP output");
555    }
556
557    #[test]
558    fn test_constructor_add_sp_label() {
559        let psbt = test_psbt_with_outputs(1);
560        let scan = ScanPublicKey::from(test_pubkey(1));
561        let spend = SpendPublicKey::from(test_pubkey(2));
562
563        let constructor = SpPsbtConstructor::new(psbt).unwrap();
564        let constructor = constructor.add_sp_output(0, &scan, &spend).unwrap();
565        let constructor = constructor.add_sp_label(0, 42).unwrap();
566
567        let updater = constructor.into_updater().unwrap();
568        let label = fields::get_sp_output_label(&updater.inner().outputs[0]);
569        assert_eq!(label, Some(42));
570    }
571
572    // --- Updater tests ---
573
574    #[test]
575    fn test_updater_inner_mut_access() {
576        let psbt = test_psbt_with_outputs(2);
577        let scan = ScanPublicKey::from(test_pubkey(1));
578        let spend = SpendPublicKey::from(test_pubkey(2));
579
580        let constructor = SpPsbtConstructor::new(psbt).unwrap();
581        let constructor = constructor.add_sp_output(0, &scan, &spend).unwrap();
582        let mut updater = constructor.into_updater().unwrap();
583
584        // Wallet can modify the PSBT via inner_mut
585        let psbt_mut = updater.inner_mut();
586        assert_eq!(psbt_mut.outputs.len(), 2, "should have 2 outputs");
587
588        // Modify something to prove write access works
589        let original_len = psbt_mut.inputs.len();
590        assert!(original_len > 0, "should have at least one input");
591    }
592
593    #[test]
594    fn test_updater_into_signer() {
595        let psbt = test_psbt_with_outputs(1);
596        let scan = ScanPublicKey::from(test_pubkey(1));
597        let spend = SpendPublicKey::from(test_pubkey(2));
598
599        let constructor = SpPsbtConstructor::new(psbt).unwrap();
600        let constructor = constructor.add_sp_output(0, &scan, &spend).unwrap();
601        let updater = constructor.into_updater().unwrap();
602
603        // Should transition without error
604        let _signer = updater.into_signer();
605    }
606
607    // --- Signer tests ---
608
609    #[cfg(feature = "experimental-dleq")]
610    #[test]
611    fn test_signer_add_ecdh_share_validates_dleq() {
612        let secp = Secp256k1::new();
613        let scan_sk = test_secret_key(10);
614        let scan_pk = ScanPublicKey::from(scan_sk.public_key(&secp));
615        // Signer's secret key (the input key)
616        let signer_sk = test_secret_key(5);
617        let signer_pk = signer_sk.public_key(&secp);
618
619        // Generate a valid DLEQ proof
620        let aux_rand = [0x42u8; 32];
621        let (ecdh_share, proof) =
622            DleqProof::generate(&signer_sk, scan_pk.as_inner(), &aux_rand, &secp).unwrap();
623
624        let signer = build_signer(1, 1, 10, 20);
625        let signer = signer
626            .add_ecdh_share(0, &scan_pk, &ecdh_share, &proof, &signer_pk, &secp)
627            .expect("valid DLEQ proof should be accepted");
628
629        // Verify the share was stored
630        let result = signer.into_extractor();
631        assert!(result.is_ok(), "should transition with all shares present");
632    }
633
634    #[test]
635    fn test_signer_rejects_invalid_dleq() {
636        let secp = Secp256k1::new();
637        let scan_pk = ScanPublicKey::from(test_pubkey(10));
638        let signer_pk = test_pubkey(5);
639        let ecdh_share = test_pubkey(30); // Not a real ECDH share
640
641        // Garbage DLEQ proof
642        let proof = DleqProof::from_bytes([0xAB; 64]);
643
644        let signer = build_signer(1, 1, 10, 20);
645        let result = signer.add_ecdh_share(0, &scan_pk, &ecdh_share, &proof, &signer_pk, &secp);
646
647        assert!(
648            matches!(result, Err(PsbtError::InvalidProof(_))),
649            "should reject invalid DLEQ proof, got: {:?}",
650            result.err()
651        );
652    }
653
654    #[cfg(feature = "experimental-dleq")]
655    #[test]
656    fn test_signer_rejects_duplicate_global_and_per_input() {
657        let secp = Secp256k1::new();
658        let scan_sk = test_secret_key(10);
659        let scan_pk = ScanPublicKey::from(scan_sk.public_key(&secp));
660
661        let signer_sk = test_secret_key(5);
662        let signer_pk = signer_sk.public_key(&secp);
663
664        let aux_rand = [0x42u8; 32];
665        let (ecdh_share, proof) =
666            DleqProof::generate(&signer_sk, scan_pk.as_inner(), &aux_rand, &secp).unwrap();
667
668        // Generate a second proof for global share
669        let aux_rand2 = [0x43u8; 32];
670        let (ecdh_share2, proof2) =
671            DleqProof::generate(&signer_sk, scan_pk.as_inner(), &aux_rand2, &secp).unwrap();
672
673        let signer = build_signer(1, 1, 10, 20);
674
675        // Add global share first
676        let signer = signer
677            .add_global_ecdh_share(&scan_pk, &ecdh_share, &proof, &signer_pk, &secp)
678            .unwrap();
679
680        // Try to add per-input share for same scan key -- should fail
681        let result = signer.add_ecdh_share(0, &scan_pk, &ecdh_share2, &proof2, &signer_pk, &secp);
682        assert!(
683            matches!(result, Err(PsbtError::DuplicateShareType { .. })),
684            "should reject per-input share when global exists, got: {:?}",
685            result.err()
686        );
687    }
688
689    #[cfg(feature = "experimental-dleq")]
690    #[test]
691    fn test_signer_rejects_global_when_per_input_exists() {
692        let secp = Secp256k1::new();
693        let scan_sk = test_secret_key(10);
694        let scan_pk = ScanPublicKey::from(scan_sk.public_key(&secp));
695
696        let signer_sk = test_secret_key(5);
697        let signer_pk = signer_sk.public_key(&secp);
698
699        let aux_rand = [0x42u8; 32];
700        let (ecdh_share, proof) =
701            DleqProof::generate(&signer_sk, scan_pk.as_inner(), &aux_rand, &secp).unwrap();
702
703        let aux_rand2 = [0x43u8; 32];
704        let (ecdh_share2, proof2) =
705            DleqProof::generate(&signer_sk, scan_pk.as_inner(), &aux_rand2, &secp).unwrap();
706
707        let signer = build_signer(1, 1, 10, 20);
708
709        // Add per-input share first
710        let signer = signer
711            .add_ecdh_share(0, &scan_pk, &ecdh_share, &proof, &signer_pk, &secp)
712            .unwrap();
713
714        // Try to add global share for same scan key -- should fail
715        let result =
716            signer.add_global_ecdh_share(&scan_pk, &ecdh_share2, &proof2, &signer_pk, &secp);
717        assert!(
718            matches!(result, Err(PsbtError::DuplicateShareType { .. })),
719            "should reject global share when per-input exists, got: {:?}",
720            result.err()
721        );
722    }
723
724    #[test]
725    fn test_signer_into_extractor_requires_all_shares() {
726        // Signer with no shares added at all
727        let signer = build_signer(1, 1, 10, 20);
728
729        let result = signer.into_extractor();
730        assert!(
731            matches!(result, Err(PsbtError::IncompleteSigning)),
732            "should require all ECDH shares, got: {:?}",
733            result.err()
734        );
735    }
736
737    #[cfg(feature = "experimental-dleq")]
738    #[test]
739    fn test_signer_into_extractor_succeeds_with_global_share() {
740        let secp = Secp256k1::new();
741        let scan_sk = test_secret_key(10);
742        let scan_pk = ScanPublicKey::from(scan_sk.public_key(&secp));
743
744        let signer_sk = test_secret_key(5);
745        let signer_pk = signer_sk.public_key(&secp);
746
747        let aux_rand = [0x42u8; 32];
748        let (ecdh_share, proof) =
749            DleqProof::generate(&signer_sk, scan_pk.as_inner(), &aux_rand, &secp).unwrap();
750
751        let signer = build_signer(1, 1, 10, 20);
752        let signer = signer
753            .add_global_ecdh_share(&scan_pk, &ecdh_share, &proof, &signer_pk, &secp)
754            .unwrap();
755
756        let result = signer.into_extractor();
757        assert!(
758            result.is_ok(),
759            "should transition with global share, got: {:?}",
760            result.err()
761        );
762    }
763
764    // --- Extractor tests ---
765
766    #[cfg(feature = "experimental-dleq")]
767    #[test]
768    fn test_extractor_single_signer_flow() {
769        let secp = Secp256k1::new();
770        let scan_sk = test_secret_key(10);
771        let scan_pk = ScanPublicKey::from(scan_sk.public_key(&secp));
772        let spend_pk = SpendPublicKey::from(test_pubkey(20));
773
774        let signer_sk = test_secret_key(5);
775        let signer_pk = signer_sk.public_key(&secp);
776
777        // Build the full flow: Constructor -> Updater -> Signer -> Extractor
778        let psbt = test_psbt(1, 1);
779        let constructor = SpPsbtConstructor::new(psbt).unwrap();
780        let constructor = constructor.add_sp_output(0, &scan_pk, &spend_pk).unwrap();
781        let updater = constructor.into_updater().unwrap();
782        let signer = updater.into_signer();
783
784        // Generate and add global ECDH share
785        let aux_rand = [0x42u8; 32];
786        let (ecdh_share, proof) =
787            DleqProof::generate(&signer_sk, scan_pk.as_inner(), &aux_rand, &secp).unwrap();
788
789        let signer = signer
790            .add_global_ecdh_share(&scan_pk, &ecdh_share, &proof, &signer_pk, &secp)
791            .unwrap();
792
793        let extractor = signer.into_extractor().unwrap();
794        let (psbt, outputs) = extractor.extract(&secp).unwrap();
795
796        // Verify outputs
797        assert_eq!(outputs.len(), 1, "should produce one extracted output");
798        assert_eq!(outputs[0].output_index(), 0);
799        assert_eq!(outputs[0].scan_pubkey(), scan_pk.as_inner());
800
801        // Verify the P2TR script is valid (34 bytes: OP_1 + PUSH32 + 32-byte key)
802        let script = outputs[0].script_pubkey();
803        assert_eq!(script.len(), 34, "P2TR script should be 34 bytes");
804        assert_eq!(script.as_bytes()[0], 0x51, "should start with OP_1");
805        assert_eq!(script.as_bytes()[1], 0x20, "should have PUSH32");
806
807        // Verify PSBT output scriptPubKey was updated
808        assert_eq!(
809            psbt.unsigned_tx.output[0].script_pubkey, *script,
810            "PSBT output scriptPubKey should be updated"
811        );
812    }
813
814    #[cfg(feature = "experimental-dleq")]
815    #[test]
816    fn test_extractor_updates_psbt_outputs() {
817        let secp = Secp256k1::new();
818        let scan_sk = test_secret_key(10);
819        let scan_pk = ScanPublicKey::from(scan_sk.public_key(&secp));
820        let spend_pk = SpendPublicKey::from(test_pubkey(20));
821
822        let signer_sk = test_secret_key(5);
823        let signer_pk = signer_sk.public_key(&secp);
824
825        let psbt = test_psbt(1, 2);
826        let constructor = SpPsbtConstructor::new(psbt).unwrap();
827        let constructor = constructor.add_sp_output(0, &scan_pk, &spend_pk).unwrap();
828        let updater = constructor.into_updater().unwrap();
829        let signer = updater.into_signer();
830
831        let aux_rand = [0x42u8; 32];
832        let (ecdh_share, proof) =
833            DleqProof::generate(&signer_sk, scan_pk.as_inner(), &aux_rand, &secp).unwrap();
834
835        let signer = signer
836            .add_global_ecdh_share(&scan_pk, &ecdh_share, &proof, &signer_pk, &secp)
837            .unwrap();
838
839        let extractor = signer.into_extractor().unwrap();
840        let (psbt, _outputs) = extractor.extract(&secp).unwrap();
841
842        // Output 0 should have a P2TR script (updated by extractor)
843        let script0 = &psbt.unsigned_tx.output[0].script_pubkey;
844        assert_eq!(script0.len(), 34, "SP output should have P2TR script");
845
846        // Output 1 should remain unchanged (empty script, no SP info)
847        let script1 = &psbt.unsigned_tx.output[1].script_pubkey;
848        assert!(script1.is_empty(), "non-SP output should be unchanged");
849    }
850
851    #[cfg(feature = "experimental-dleq")]
852    #[test]
853    fn test_extractor_returns_metadata() {
854        let secp = Secp256k1::new();
855        let scan_sk = test_secret_key(10);
856        let scan_pk = ScanPublicKey::from(scan_sk.public_key(&secp));
857        let spend_pk = SpendPublicKey::from(test_pubkey(20));
858
859        let signer_sk = test_secret_key(5);
860        let signer_pk = signer_sk.public_key(&secp);
861
862        let psbt = test_psbt(1, 1);
863        let constructor = SpPsbtConstructor::new(psbt).unwrap();
864        let constructor = constructor.add_sp_output(0, &scan_pk, &spend_pk).unwrap();
865        let updater = constructor.into_updater().unwrap();
866        let signer = updater.into_signer();
867
868        let aux_rand = [0x42u8; 32];
869        let (ecdh_share, proof) =
870            DleqProof::generate(&signer_sk, scan_pk.as_inner(), &aux_rand, &secp).unwrap();
871
872        let signer = signer
873            .add_global_ecdh_share(&scan_pk, &ecdh_share, &proof, &signer_pk, &secp)
874            .unwrap();
875
876        let extractor = signer.into_extractor().unwrap();
877        let (_psbt, outputs) = extractor.extract(&secp).unwrap();
878
879        // Verify metadata
880        assert_eq!(outputs.len(), 1);
881        let output = &outputs[0];
882        assert_eq!(output.output_index(), 0, "should reference output index 0");
883        assert_eq!(
884            output.scan_pubkey(),
885            scan_pk.as_inner(),
886            "should have correct scan pubkey"
887        );
888
889        // x_only_pubkey should be 32 bytes
890        assert_eq!(output.x_only_pubkey().serialize().len(), 32);
891
892        // script_pubkey should be a valid P2TR script containing the x_only_pubkey
893        let script = output.script_pubkey();
894        let xonly_bytes = output.x_only_pubkey().serialize();
895        assert_eq!(
896            &script.as_bytes()[2..34],
897            &xonly_bytes,
898            "P2TR script should embed the x-only pubkey"
899        );
900    }
901}