Skip to main content

silent_payments_receive/
scanner.rs

1//! Block-level transaction scanner for Silent Payments.
2//!
3//! [`BlockScanner`] wraps bdk-sp's receive primitives with type-safe
4//! `silent-payments-core` newtypes and explicit error handling.
5//!
6//! # Usage
7//!
8//! ```no_run
9//! # use silent_payments_receive::{BlockScanner, LabelManager};
10//! # use silent_payments_core::keys::{ScanSecretKey, SpendSecretKey, SpendPublicKey};
11//! # use bitcoin::secp256k1::Secp256k1;
12//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
13//! # let secp = Secp256k1::new();
14//! # let scan_secret = ScanSecretKey::from_slice(&[0x01; 32])?;
15//! # let spend_secret = SpendSecretKey::from_slice(&[0x02; 32])?;
16//! # let spend_pubkey = spend_secret.public_key(&secp);
17//! # let labels = LabelManager::new();
18//! let scanner = BlockScanner::new(scan_secret, spend_pubkey, labels);
19//! // for tx in block.txdata {
20//! //     let detected = scanner.scan_transaction(&tx, &prevouts, &secp)?;
21//! //     for output in detected {
22//! //         let spend_key = output.derive_spend_key(&spend_secret, &secp)?;
23//! //         // sign with spend_key, then erase it
24//! //     }
25//! // }
26//! # Ok(())
27//! # }
28//! ```
29
30use bitcoin::secp256k1::Secp256k1;
31use bitcoin::{Transaction, TxOut};
32
33use crate::detected::DetectedOutput;
34use crate::error::ReceiveError;
35use crate::label::LabelManager;
36use silent_payments_core::keys::{ScanSecretKey, SpendPublicKey};
37
38/// Scans transactions for Silent Payment outputs addressed to the receiver.
39///
40/// Holds the receiver's scan secret key, spend public key, and optional
41/// labels. For each transaction, computes the ECDH shared secret and
42/// checks P2TR outputs for matches.
43///
44/// Non-SP transactions (no P2TR outputs, no eligible inputs) return an
45/// empty `Vec` without error -- the receiver simply skips them.
46pub struct BlockScanner {
47    scan_secret: ScanSecretKey,
48    spend_pubkey: SpendPublicKey,
49    labels: LabelManager,
50}
51
52impl BlockScanner {
53    /// Create a new scanner for the given receiver keys and labels.
54    pub fn new(
55        scan_secret: ScanSecretKey,
56        spend_pubkey: SpendPublicKey,
57        labels: LabelManager,
58    ) -> Self {
59        Self {
60            scan_secret,
61            spend_pubkey,
62            labels,
63        }
64    }
65
66    /// Scan a single transaction for SP payments addressed to this receiver.
67    ///
68    /// Returns detected outputs with their tweaks and optional label indices.
69    /// Returns an empty `Vec` for non-SP transactions (no P2TR outputs or
70    /// no eligible inputs). This is not an error -- receivers scan all
71    /// transactions and skip non-matching ones.
72    ///
73    /// # Arguments
74    ///
75    /// * `tx` -- The transaction to scan.
76    /// * `prevouts` -- Previous outputs for each input (same order as `tx.input`).
77    /// * `secp` -- Secp256k1 context for EC operations.
78    ///
79    /// # Errors
80    ///
81    /// Returns [`ReceiveError::Scanning`] if bdk-sp's output scanning fails
82    /// (should not happen with well-formed transactions).
83    pub fn scan_transaction(
84        &self,
85        tx: &Transaction,
86        prevouts: &[TxOut],
87        secp: &Secp256k1<bitcoin::secp256k1::All>,
88    ) -> Result<Vec<DetectedOutput>, ReceiveError> {
89        // Quick check: skip transactions without P2TR outputs
90        let has_p2tr = tx.output.iter().any(|out| out.script_pubkey.is_p2tr());
91        if !has_p2tr {
92            return Ok(Vec::new());
93        }
94
95        // Step 1: Compute tweak data (A_sum * input_hash)
96        // If no eligible inputs, return empty -- not an error for receivers
97        let tweak_data = match bdk_sp::receive::compute_tweak_data(tx, prevouts) {
98            Ok(td) => td,
99            Err(_) => return Ok(Vec::new()),
100        };
101
102        // Step 2: ECDH shared secret: b_scan * tweak_data
103        let ecdh_shared_secret =
104            bdk_sp::compute_shared_secret(self.scan_secret.as_inner(), &tweak_data);
105
106        // Step 3: Convert labels to BTreeMap for bdk-sp
107        let labels_btree = self.labels.to_btree();
108
109        // Step 4: Scan outputs for matches
110        let sp_outs = bdk_sp::receive::scan_txouts(
111            *self.spend_pubkey.as_inner(),
112            &labels_btree,
113            tx,
114            ecdh_shared_secret,
115        )
116        .map_err(|e| ReceiveError::Scanning(e.to_string()))?;
117
118        // Step 5: Convert SpOut -> DetectedOutput
119        let detected = sp_outs
120            .into_iter()
121            .map(|sp_out| {
122                DetectedOutput::new(
123                    sp_out.outpoint,
124                    sp_out.amount,
125                    sp_out.script_pubkey,
126                    sp_out.tweak,
127                    sp_out.label,
128                )
129            })
130            .collect();
131
132        // SEC-01: ecdh_shared_secret is a PublicKey (not secret material)
133        // tweak_data is a PublicKey (not secret material)
134        // The intermediate secrets (t_k) are contained in bdk-sp's scan_txouts
135        // and erased when it returns.
136        let _ = secp; // Used by caller for derive_spend_key, not needed here
137
138        Ok(detected)
139    }
140
141    /// Access the scan secret key (for testing/debugging).
142    pub fn scan_secret(&self) -> &ScanSecretKey {
143        &self.scan_secret
144    }
145
146    /// Access the spend public key (for testing/debugging).
147    pub fn spend_pubkey(&self) -> &SpendPublicKey {
148        &self.spend_pubkey
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use bitcoin::secp256k1::{Secp256k1, SecretKey};
156    use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness};
157    use silent_payments_core::keys::{ScanSecretKey, SpendSecretKey};
158
159    const SCAN_SK_BYTES: [u8; 32] = [
160        0xea, 0xdc, 0x78, 0x16, 0x5f, 0xf1, 0xf8, 0xea, 0x94, 0xad, 0x7c, 0xfd, 0xc5, 0x49, 0x90,
161        0x73, 0x8a, 0x4c, 0x53, 0xf6, 0xe0, 0x50, 0x7b, 0x42, 0x15, 0x42, 0x01, 0xb8, 0xe5, 0xdf,
162        0xf3, 0xb1,
163    ];
164
165    const SPEND_SK_BYTES: [u8; 32] = [
166        0x93, 0xf5, 0xed, 0x90, 0x7a, 0xd5, 0xb2, 0xbd, 0xbb, 0xdc, 0xb5, 0xd9, 0x11, 0x6e, 0xbc,
167        0x0a, 0x4e, 0x1f, 0x92, 0xf9, 0x10, 0xd5, 0x26, 0x02, 0x37, 0xfa, 0x45, 0xa9, 0x40, 0x8a,
168        0xad, 0x16,
169    ];
170
171    fn test_scanner() -> BlockScanner {
172        let secp = Secp256k1::new();
173        let scan_sk = ScanSecretKey::from_slice(&SCAN_SK_BYTES).unwrap();
174        let spend_sk = SpendSecretKey::from_slice(&SPEND_SK_BYTES).unwrap();
175        let spend_pk = spend_sk.public_key(&secp);
176        BlockScanner::new(scan_sk, spend_pk, LabelManager::new())
177    }
178
179    #[test]
180    fn test_no_p2tr_outputs_returns_empty() {
181        let secp = Secp256k1::new();
182        let scanner = test_scanner();
183
184        // Create a tx with only a P2WPKH output (not P2TR)
185        let tx = Transaction {
186            version: bitcoin::transaction::Version::TWO,
187            lock_time: bitcoin::absolute::LockTime::ZERO,
188            input: vec![TxIn {
189                previous_output: OutPoint::null(),
190                script_sig: ScriptBuf::new(),
191                sequence: Sequence::MAX,
192                witness: Witness::new(),
193            }],
194            output: vec![TxOut {
195                value: Amount::from_sat(50_000),
196                // P2WPKH script (not P2TR)
197                script_pubkey: ScriptBuf::from_hex("001453d9c40342ee880e766522c3e2b854d37f2b3cbf")
198                    .unwrap(),
199            }],
200        };
201
202        let prevouts = vec![TxOut {
203            value: Amount::from_sat(100_000),
204            script_pubkey: ScriptBuf::from_hex("001453d9c40342ee880e766522c3e2b854d37f2b3cbf")
205                .unwrap(),
206        }];
207
208        let result = scanner.scan_transaction(&tx, &prevouts, &secp).unwrap();
209        assert!(result.is_empty(), "non-P2TR tx should return empty vec");
210    }
211
212    #[test]
213    fn test_no_eligible_inputs_returns_empty() {
214        let secp = Secp256k1::new();
215        let scanner = test_scanner();
216
217        // P2TR output but no valid input (null input with no witness/script)
218        let dummy_sk = SecretKey::from_slice(&[0x42; 32]).unwrap();
219        let (xonly, _) = dummy_sk.public_key(&secp).x_only_public_key();
220        let tweaked = bitcoin::key::TweakedPublicKey::dangerous_assume_tweaked(xonly);
221        let p2tr_script = ScriptBuf::new_p2tr_tweaked(tweaked);
222
223        let tx = Transaction {
224            version: bitcoin::transaction::Version::TWO,
225            lock_time: bitcoin::absolute::LockTime::ZERO,
226            input: vec![TxIn {
227                previous_output: OutPoint::null(),
228                script_sig: ScriptBuf::new(),
229                sequence: Sequence::MAX,
230                witness: Witness::new(),
231            }],
232            output: vec![TxOut {
233                value: Amount::from_sat(50_000),
234                script_pubkey: p2tr_script,
235            }],
236        };
237
238        // Prevout with a script type that won't extract a pubkey
239        let prevouts = vec![TxOut {
240            value: Amount::from_sat(100_000),
241            // P2WSH script -- not eligible for SP
242            script_pubkey: ScriptBuf::from_hex(
243                "0020000000000000000000000000000000000000000000000000000000000000dead",
244            )
245            .unwrap(),
246        }];
247
248        let result = scanner.scan_transaction(&tx, &prevouts, &secp).unwrap();
249        assert!(
250            result.is_empty(),
251            "tx with no eligible inputs should return empty vec"
252        );
253    }
254
255    #[test]
256    fn accessors_return_correct_references() {
257        let scanner = test_scanner();
258        // Just verify the accessors don't panic
259        let _scan = scanner.scan_secret();
260        let _spend = scanner.spend_pubkey();
261    }
262}