Skip to main content

silent_payments_receive/
detected.rs

1//! Detected Silent Payment output with spend key derivation.
2//!
3//! [`DetectedOutput`] represents a matched output from transaction scanning.
4//! It contains all metadata needed for UTXO tracking and spending:
5//! outpoint, amount, script_pubkey, optional label index, and the private
6//! tweak needed to derive the spending key.
7//!
8//! # Security
9//!
10//! The `tweak` field is private. Spend key derivation is exposed via
11//! [`DetectedOutput::derive_spend_key`], which returns a `SecretKey`
12//! that the caller **must** erase after use (SEC-01).
13
14use bitcoin::secp256k1::{Scalar, Secp256k1, SecretKey};
15use bitcoin::{Amount, OutPoint, ScriptBuf, XOnlyPublicKey};
16
17use crate::error::ReceiveError;
18use silent_payments_core::keys::SpendSecretKey;
19
20/// A Silent Payment output detected during transaction scanning.
21///
22/// Created by [`crate::BlockScanner::scan_transaction`] when an output matches
23/// the receiver's keys (with or without a label).
24///
25/// # Spending
26///
27/// Call [`derive_spend_key`](DetectedOutput::derive_spend_key) with the
28/// receiver's spend secret key to obtain the tweaked private key for
29/// signing. The returned `SecretKey` **must** be erased after use.
30#[derive(Debug, Clone)]
31pub struct DetectedOutput {
32    /// The outpoint (txid:vout) identifying this output.
33    pub outpoint: OutPoint,
34    /// The output amount in satoshis.
35    pub amount: Amount,
36    /// The output's scriptPubKey (P2TR).
37    pub script_pubkey: ScriptBuf,
38    /// The tweak scalar t_k (includes label_tweak if labeled).
39    /// Private to prevent accidental exposure of secret material.
40    tweak: SecretKey,
41    /// The label index if this output matched via a labeled address.
42    pub label: Option<u32>,
43}
44
45impl DetectedOutput {
46    /// Create a new detected output.
47    pub fn new(
48        outpoint: OutPoint,
49        amount: Amount,
50        script_pubkey: ScriptBuf,
51        tweak: SecretKey,
52        label: Option<u32>,
53    ) -> Self {
54        Self {
55            outpoint,
56            amount,
57            script_pubkey,
58            tweak,
59            label,
60        }
61    }
62
63    /// Derive the tweaked private key for spending this output.
64    ///
65    /// Computes `spend_key = b_spend + tweak` where the tweak already
66    /// includes the label scalar if the output was received via a
67    /// labeled address.
68    ///
69    /// # Security (SEC-01)
70    ///
71    /// The returned `SecretKey` contains the full spending private key.
72    /// The caller **must** erase it via `non_secure_erase()` after signing.
73    ///
74    /// # Errors
75    ///
76    /// Returns [`ReceiveError::SpendKeyDerivation`] if:
77    /// - The tweak addition overflows the curve order
78    /// - The derived public key does not match the output script
79    pub fn derive_spend_key(
80        &self,
81        spend_secret: &SpendSecretKey,
82        secp: &Secp256k1<bitcoin::secp256k1::All>,
83    ) -> Result<SecretKey, ReceiveError> {
84        // Convert SecretKey tweak to Scalar for add_tweak
85        let tweak_scalar = Scalar::from_be_bytes(self.tweak.secret_bytes())
86            .map_err(|_| ReceiveError::SpendKeyDerivation("tweak scalar is zero".into()))?;
87
88        // spend_key = b_spend + tweak
89        let spend_key = spend_secret
90            .as_inner()
91            .add_tweak(&tweak_scalar)
92            .map_err(|e| ReceiveError::SpendKeyDerivation(format!("tweak addition failed: {e}")))?;
93
94        // Verify the derived pubkey matches the output script's x-only key
95        let (derived_xonly, _parity) = spend_key.public_key(secp).x_only_public_key();
96
97        // Extract x-only key from the P2TR script (bytes 2..34)
98        let script_bytes = self.script_pubkey.as_bytes();
99        if script_bytes.len() >= 34 {
100            let expected_xonly = XOnlyPublicKey::from_slice(&script_bytes[2..34]).map_err(|e| {
101                ReceiveError::SpendKeyDerivation(format!("invalid x-only key in script: {e}"))
102            })?;
103
104            if derived_xonly != expected_xonly {
105                return Err(ReceiveError::SpendKeyDerivation(format!(
106                    "derived key {derived_xonly} does not match output key {expected_xonly}"
107                )));
108            }
109        }
110
111        Ok(spend_key)
112    }
113
114    /// Access the tweak scalar for advanced use (e.g., PSBT Phase 3).
115    pub fn tweak_secret_key(&self) -> &SecretKey {
116        &self.tweak
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use bitcoin::key::TweakedPublicKey;
124    use bitcoin::secp256k1::{Secp256k1, SecretKey};
125    use silent_payments_core::keys::SpendSecretKey;
126
127    // Known valid secret key bytes
128    const SPEND_SK_BYTES: [u8; 32] = [
129        0x93, 0xf5, 0xed, 0x90, 0x7a, 0xd5, 0xb2, 0xbd, 0xbb, 0xdc, 0xb5, 0xd9, 0x11, 0x6e, 0xbc,
130        0x0a, 0x4e, 0x1f, 0x92, 0xf9, 0x10, 0xd5, 0x26, 0x02, 0x37, 0xfa, 0x45, 0xa9, 0x40, 0x8a,
131        0xad, 0x16,
132    ];
133
134    const TWEAK_BYTES: [u8; 32] = [
135        0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
136        0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e,
137        0x1f, 0x20,
138    ];
139
140    #[test]
141    fn derive_spend_key_produces_valid_key() {
142        let secp = Secp256k1::new();
143        let spend_sk = SpendSecretKey::from_slice(&SPEND_SK_BYTES).unwrap();
144        let tweak = SecretKey::from_slice(&TWEAK_BYTES).unwrap();
145
146        // Compute what the output script should be
147        let tweak_scalar = Scalar::from_be_bytes(TWEAK_BYTES).unwrap();
148        let expected_spend_key = spend_sk.as_inner().add_tweak(&tweak_scalar).unwrap();
149        let (expected_xonly, _) = expected_spend_key.public_key(&secp).x_only_public_key();
150
151        // Build the P2TR script from the expected key
152        let tweaked_pk = TweakedPublicKey::dangerous_assume_tweaked(expected_xonly);
153        let script = ScriptBuf::new_p2tr_tweaked(tweaked_pk);
154
155        let detected = DetectedOutput::new(
156            OutPoint::null(),
157            Amount::from_sat(100_000),
158            script,
159            tweak,
160            None,
161        );
162
163        let derived = detected.derive_spend_key(&spend_sk, &secp).unwrap();
164
165        // The derived key should match our expected computation
166        let (derived_xonly, _) = derived.public_key(&secp).x_only_public_key();
167        assert_eq!(derived_xonly, expected_xonly);
168    }
169
170    #[test]
171    fn derive_spend_key_fails_on_script_mismatch() {
172        let secp = Secp256k1::new();
173        let spend_sk = SpendSecretKey::from_slice(&SPEND_SK_BYTES).unwrap();
174        let tweak = SecretKey::from_slice(&TWEAK_BYTES).unwrap();
175
176        // Use a different key for the script (will not match)
177        let wrong_sk = SecretKey::from_slice(&[0x42; 32]).unwrap();
178        let (wrong_xonly, _) = wrong_sk.public_key(&secp).x_only_public_key();
179        let tweaked_pk = TweakedPublicKey::dangerous_assume_tweaked(wrong_xonly);
180        let script = ScriptBuf::new_p2tr_tweaked(tweaked_pk);
181
182        let detected = DetectedOutput::new(
183            OutPoint::null(),
184            Amount::from_sat(100_000),
185            script,
186            tweak,
187            None,
188        );
189
190        let result = detected.derive_spend_key(&spend_sk, &secp);
191        assert!(
192            matches!(result, Err(ReceiveError::SpendKeyDerivation(_))),
193            "should fail with script mismatch, got: {:?}",
194            result
195        );
196    }
197
198    #[test]
199    fn tweak_secret_key_accessor() {
200        let tweak = SecretKey::from_slice(&TWEAK_BYTES).unwrap();
201        let detected = DetectedOutput::new(
202            OutPoint::null(),
203            Amount::from_sat(50_000),
204            ScriptBuf::new(),
205            tweak,
206            Some(1),
207        );
208
209        assert_eq!(detected.tweak_secret_key().secret_bytes(), TWEAK_BYTES);
210        assert_eq!(detected.label, Some(1));
211    }
212
213    #[test]
214    fn new_sets_all_fields_correctly() {
215        let tweak = SecretKey::from_slice(&TWEAK_BYTES).unwrap();
216        let outpoint = OutPoint::null();
217        let amount = Amount::from_sat(42_000);
218        let script = ScriptBuf::new();
219
220        let detected = DetectedOutput::new(outpoint, amount, script.clone(), tweak, Some(5));
221
222        assert_eq!(detected.outpoint, outpoint);
223        assert_eq!(detected.amount, amount);
224        assert_eq!(detected.script_pubkey, script);
225        assert_eq!(detected.label, Some(5));
226    }
227}