Skip to main content

silent_payments_scan/
index_server.rs

1//! BIP0352 index server scanning backend.
2//!
3//! [`IndexServerBackend`] implements [`ScanBackend`] for scanning via a BIP0352
4//! index server (e.g., BlindBit oracle). The server provides pre-computed tweak
5//! data (`A_sum * input_hash`) per block, enabling lightweight client-side
6//! scanning without full transaction data.
7//!
8//! # Protocol
9//!
10//! For each block in the scan range:
11//! 1. Fetch tweaks via `GET /tweaks/:height` (pre-computed `A_sum * input_hash`)
12//! 2. Fetch UTXOs via `GET /utxos/:height` (taproot outputs in the block)
13//! 3. For each tweak: compute ECDH shared secret, derive candidate script pubkeys
14//! 4. Match candidates against the UTXO set
15//! 5. Report matches via `on_match` callback, progress via `on_progress`
16//!
17//! # Feature Gate
18//!
19//! This module requires the `index-server` feature flag.
20
21use bitcoin::secp256k1::PublicKey;
22use bitcoin::{Amount, OutPoint, ScriptBuf, Txid};
23use serde::Deserialize;
24
25use silent_payments_core::keys::{ScanSecretKey, SpendPublicKey};
26use silent_payments_receive::label::LabelManager;
27use silent_payments_receive::DetectedOutput;
28
29use crate::backend::{OnMatch, OnProgress, ScanBackend};
30use crate::error::ScanError;
31
32type UtxoEntry = (Txid, u32, Amount, ScriptBuf);
33type UtxoLookup = std::collections::HashMap<Vec<u8>, Vec<UtxoEntry>>;
34
35/// A tweak entry from the index server's `/tweaks/:height` endpoint.
36///
37/// Each tweak represents a transaction's pre-computed `A_sum * input_hash`
38/// as a hex-encoded compressed public key.
39#[derive(Debug, Deserialize)]
40struct TweakResponse {
41    /// Transaction ID that produced this tweak.
42    txid: String,
43    /// Hex-encoded compressed public key (`A_sum * input_hash`).
44    tweak_data: String,
45}
46
47/// A UTXO entry from the index server's `/utxos/:height` endpoint.
48#[derive(Debug, Deserialize)]
49struct UtxoResponse {
50    /// Transaction ID containing this output.
51    txid: String,
52    /// Output index within the transaction.
53    vout: u32,
54    /// Output value in satoshis.
55    value: u64,
56    /// Hex-encoded scriptPubKey.
57    #[serde(alias = "scriptPubKey")]
58    script_pub_key: String,
59}
60
61/// Scanning backend for BIP0352 index servers.
62///
63/// Connects to a BIP0352-compatible index server (e.g., BlindBit oracle)
64/// that provides pre-computed tweak data per block. This enables lightweight
65/// scanning with minimal bandwidth -- the client only performs ECDH and
66/// script pubkey derivation, not full transaction parsing.
67///
68/// # Construction
69///
70/// ```ignore
71/// // Unauthenticated
72/// let backend = IndexServerBackend::new("http://localhost:8080");
73///
74/// // With optional auth token
75/// let backend = IndexServerBackend::with_auth("http://localhost:8080", "my-token");
76/// ```
77///
78/// # No Retry Logic
79///
80/// Connection errors bubble up immediately. The caller owns retry policy.
81pub struct IndexServerBackend {
82    /// Base URL of the index server (trailing slash trimmed).
83    base_url: String,
84    /// Optional authorization token sent as Bearer header.
85    auth_token: Option<String>,
86}
87
88impl IndexServerBackend {
89    /// Create a new index server backend.
90    ///
91    /// The `base_url` trailing slash is trimmed for consistent path joining.
92    pub fn new(base_url: &str) -> Self {
93        Self {
94            base_url: base_url.trim_end_matches('/').to_string(),
95            auth_token: None,
96        }
97    }
98
99    /// Create a new index server backend with an authorization token.
100    ///
101    /// The token is sent as a `Bearer` token in the `Authorization` header.
102    pub fn with_auth(base_url: &str, token: &str) -> Self {
103        Self {
104            base_url: base_url.trim_end_matches('/').to_string(),
105            auth_token: Some(token.to_string()),
106        }
107    }
108
109    /// Fetch JSON from the index server.
110    ///
111    /// Builds the full URL from `base_url + path`, adds auth header if present,
112    /// and deserializes the response body.
113    fn fetch_json<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T, ScanError> {
114        let url = format!("{}{}", self.base_url, path);
115
116        let mut request = minreq::get(&url);
117        if let Some(ref token) = self.auth_token {
118            request = request.with_header("Authorization", format!("Bearer {token}"));
119        }
120
121        let response = request
122            .send()
123            .map_err(|e| ScanError::Connection(e.to_string()))?;
124
125        if response.status_code != 200 {
126            return Err(ScanError::Backend(format!(
127                "HTTP {}: {}",
128                response.status_code,
129                response.as_str().unwrap_or("unknown error")
130            )));
131        }
132
133        serde_json::from_str(
134            response
135                .as_str()
136                .map_err(|e| ScanError::Backend(format!("invalid UTF-8 response: {e}")))?,
137        )
138        .map_err(|e| ScanError::Backend(format!("JSON parse error: {e}")))
139    }
140
141    /// Derive candidate script pubkeys from a tweak for matching against UTXOs.
142    ///
143    /// For each tweak (pre-computed `A_sum * input_hash` from the server):
144    /// 1. Compute ECDH shared secret: `b_scan * tweak`
145    /// 2. Derive base SPK at k=0 (unlabeled)
146    /// 3. Derive labeled SPKs for each registered label
147    ///
148    /// Returns `(script_pubkey, label_index)` pairs. `label_index` is `None`
149    /// for the base (unlabeled) SPK.
150    fn derive_candidate_spks(
151        scan_sk: &ScanSecretKey,
152        spend_pk: &SpendPublicKey,
153        tweak_pubkey: &PublicKey,
154        labels: &LabelManager,
155    ) -> Vec<(ScriptBuf, Option<u32>)> {
156        // ECDH shared secret: b_scan * tweak
157        let ecdh_shared_secret = bdk_sp::compute_shared_secret(scan_sk.as_inner(), tweak_pubkey);
158
159        let labels_btree = labels.to_btree();
160
161        // Base SPK (k=0, no label)
162        let base_spk = bdk_sp::receive::get_silentpayment_script_pubkey(
163            spend_pk.as_inner(),
164            &ecdh_shared_secret,
165            0,
166            None,
167        );
168
169        let mut candidates = vec![(base_spk, None)];
170
171        // Labeled SPKs (k=0, with each registered label)
172        for (label_pk, (_scalar, label_index)) in &labels_btree {
173            let labeled_spk = bdk_sp::receive::get_silentpayment_script_pubkey(
174                spend_pk.as_inner(),
175                &ecdh_shared_secret,
176                0,
177                Some(label_pk),
178            );
179            candidates.push((labeled_spk, Some(*label_index)));
180        }
181
182        candidates
183    }
184
185    /// Parse a hex-encoded compressed public key from the index server.
186    fn parse_tweak_pubkey(hex_str: &str) -> Result<PublicKey, ScanError> {
187        let bytes = hex_to_bytes(hex_str)
188            .map_err(|e| ScanError::Backend(format!("invalid tweak hex: {e}")))?;
189        PublicKey::from_slice(&bytes)
190            .map_err(|e| ScanError::Backend(format!("invalid tweak pubkey: {e}")))
191    }
192
193    /// Parse a txid from hex string.
194    fn parse_txid(hex_str: &str) -> Result<Txid, ScanError> {
195        hex_str
196            .parse::<Txid>()
197            .map_err(|e| ScanError::Backend(format!("invalid txid: {e}")))
198    }
199
200    /// Parse a hex-encoded script pubkey.
201    fn parse_script_pubkey(hex_str: &str) -> Result<ScriptBuf, ScanError> {
202        let bytes = hex_to_bytes(hex_str)
203            .map_err(|e| ScanError::Backend(format!("invalid script hex: {e}")))?;
204        Ok(ScriptBuf::from_bytes(bytes))
205    }
206
207    /// Access the base URL (for testing).
208    #[cfg(test)]
209    fn base_url(&self) -> &str {
210        &self.base_url
211    }
212
213    /// Access the auth token (for testing).
214    #[cfg(test)]
215    fn auth_token(&self) -> Option<&str> {
216        self.auth_token.as_deref()
217    }
218}
219
220/// Decode a hex string into bytes.
221fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
222    if hex.len() % 2 != 0 {
223        return Err("odd-length hex string".to_string());
224    }
225    (0..hex.len())
226        .step_by(2)
227        .map(|i| {
228            u8::from_str_radix(&hex[i..i + 2], 16)
229                .map_err(|e| format!("invalid hex at position {i}: {e}"))
230        })
231        .collect()
232}
233
234impl ScanBackend for IndexServerBackend {
235    fn scan_range(
236        &self,
237        from_height: u32,
238        to_height: u32,
239        scan_secret: &ScanSecretKey,
240        spend_pubkey: &SpendPublicKey,
241        labels: &LabelManager,
242        on_match: OnMatch<'_>,
243        on_progress: OnProgress<'_>,
244    ) -> Result<(), ScanError> {
245        for height in from_height..=to_height {
246            // Fetch tweaks for this block
247            let tweaks: Vec<TweakResponse> = self.fetch_json(&format!("/tweaks/{height}"))?;
248
249            // Fetch UTXOs for this block
250            let utxos: Vec<UtxoResponse> = self.fetch_json(&format!("/utxos/{height}"))?;
251
252            // Build a lookup of UTXO script pubkeys for matching
253            // Key: script_pubkey hex, Value: vec of (txid, vout, value, script)
254            let mut utxo_lookup: UtxoLookup = UtxoLookup::new();
255
256            for utxo in &utxos {
257                let txid = Self::parse_txid(&utxo.txid)?;
258                let script = Self::parse_script_pubkey(&utxo.script_pub_key)?;
259                let amount = Amount::from_sat(utxo.value);
260                utxo_lookup
261                    .entry(script.as_bytes().to_vec())
262                    .or_default()
263                    .push((txid, utxo.vout, amount, script));
264            }
265
266            // For each tweak, derive candidate SPKs and match against UTXOs
267            for tweak_resp in &tweaks {
268                let tweak_pubkey = Self::parse_tweak_pubkey(&tweak_resp.tweak_data)?;
269                let tweak_txid = Self::parse_txid(&tweak_resp.txid)?;
270
271                let candidates =
272                    Self::derive_candidate_spks(scan_secret, spend_pubkey, &tweak_pubkey, labels);
273
274                // Also derive k=1,2,... candidates if we find matches at k=0
275                // For simplicity, start with k=0 only (most common case).
276                // BIP 352 specifies incrementing k per additional output to the
277                // same recipient within a single transaction.
278                let mut k = 0u32;
279                let mut found_at_k = true;
280
281                while found_at_k {
282                    found_at_k = false;
283
284                    let current_candidates = if k == 0 {
285                        // Already computed above
286                        candidates.clone()
287                    } else {
288                        // Derive candidates for higher k values
289                        let ecdh_shared_secret =
290                            bdk_sp::compute_shared_secret(scan_secret.as_inner(), &tweak_pubkey);
291                        let labels_btree = labels.to_btree();
292
293                        let base_spk = bdk_sp::receive::get_silentpayment_script_pubkey(
294                            spend_pubkey.as_inner(),
295                            &ecdh_shared_secret,
296                            k,
297                            None,
298                        );
299                        let mut higher_candidates = vec![(base_spk, None)];
300                        for (label_pk, (_scalar, label_index)) in &labels_btree {
301                            let labeled_spk = bdk_sp::receive::get_silentpayment_script_pubkey(
302                                spend_pubkey.as_inner(),
303                                &ecdh_shared_secret,
304                                k,
305                                Some(label_pk),
306                            );
307                            higher_candidates.push((labeled_spk, Some(*label_index)));
308                        }
309                        higher_candidates
310                    };
311
312                    for (candidate_spk, label_index) in &current_candidates {
313                        let spk_bytes = candidate_spk.as_bytes().to_vec();
314                        if let Some(matching_utxos) = utxo_lookup.get(&spk_bytes) {
315                            for (utxo_txid, vout, amount, script) in matching_utxos {
316                                // Verify the UTXO belongs to the tweak's transaction
317                                // (index servers return all UTXOs for a block, but
318                                // each tweak corresponds to a specific transaction)
319                                if *utxo_txid != tweak_txid {
320                                    continue;
321                                }
322
323                                // Compute the tweak secret key for DetectedOutput
324                                let ecdh = bdk_sp::compute_shared_secret(
325                                    scan_secret.as_inner(),
326                                    &tweak_pubkey,
327                                );
328                                let t_k = bdk_sp::hashes::get_shared_secret(ecdh, k);
329
330                                // If labeled, add the label tweak
331                                let final_tweak = if let Some(label_m) = label_index {
332                                    let label_scalar = bdk_sp::hashes::get_label_tweak(
333                                        *scan_secret.as_inner(),
334                                        *label_m,
335                                    );
336                                    t_k.add_tweak(&label_scalar).map_err(|e| {
337                                        ScanError::Backend(format!(
338                                            "label tweak addition failed: {e}"
339                                        ))
340                                    })?
341                                } else {
342                                    t_k
343                                };
344
345                                let outpoint = OutPoint::new(*utxo_txid, *vout);
346                                let detected = DetectedOutput::new(
347                                    outpoint,
348                                    *amount,
349                                    script.clone(),
350                                    final_tweak,
351                                    *label_index,
352                                );
353
354                                on_match(detected);
355                                found_at_k = true;
356                            }
357                        }
358                    }
359
360                    k += 1;
361
362                    // Safety bound: BIP 352 practically limits outputs per recipient
363                    // per transaction. Prevent infinite loops.
364                    if k > 255 {
365                        break;
366                    }
367                }
368            }
369
370            on_progress(height);
371        }
372
373        Ok(())
374    }
375
376    fn scan_transaction(
377        &self,
378        _txid: &bitcoin::Txid,
379        _scan_secret: &ScanSecretKey,
380        _spend_pubkey: &SpendPublicKey,
381        _labels: &LabelManager,
382    ) -> Result<Vec<DetectedOutput>, ScanError> {
383        // The index server protocol does not support txid-to-block-height lookup.
384        // Callers should use scan_range instead.
385        Err(ScanError::Backend(
386            "IndexServer backend does not support scan_transaction: \
387             the protocol has no txid-to-block-height lookup. \
388             Use scan_range with the block height range instead."
389                .to_string(),
390        ))
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    #[test]
399    fn new_trims_trailing_slash() {
400        let backend = IndexServerBackend::new("http://localhost:8080/");
401        assert_eq!(backend.base_url(), "http://localhost:8080");
402
403        let backend = IndexServerBackend::new("http://localhost:8080///");
404        assert_eq!(backend.base_url(), "http://localhost:8080");
405
406        let backend = IndexServerBackend::new("http://localhost:8080");
407        assert_eq!(backend.base_url(), "http://localhost:8080");
408    }
409
410    #[test]
411    fn with_auth_stores_token_and_trims_slash() {
412        let backend = IndexServerBackend::with_auth("http://localhost:8080/", "my-secret-token");
413        assert_eq!(backend.base_url(), "http://localhost:8080");
414        assert_eq!(backend.auth_token(), Some("my-secret-token"));
415    }
416
417    #[test]
418    fn new_has_no_auth_token() {
419        let backend = IndexServerBackend::new("http://localhost:8080");
420        assert_eq!(backend.auth_token(), None);
421    }
422
423    #[test]
424    fn derive_candidate_spks_produces_base_spk() {
425        // Use known test keys to verify SPK derivation
426        let scan_sk = ScanSecretKey::from_slice(&[
427            0xea, 0xdc, 0x78, 0x16, 0x5f, 0xf1, 0xf8, 0xea, 0x94, 0xad, 0x7c, 0xfd, 0xc5, 0x49,
428            0x90, 0x73, 0x8a, 0x4c, 0x53, 0xf6, 0xe0, 0x50, 0x7b, 0x42, 0x15, 0x42, 0x01, 0xb8,
429            0xe5, 0xdf, 0xf3, 0xb1,
430        ])
431        .unwrap();
432
433        let spend_sk = silent_payments_core::keys::SpendSecretKey::from_slice(&[
434            0x93, 0xf5, 0xed, 0x90, 0x7a, 0xd5, 0xb2, 0xbd, 0xbb, 0xdc, 0xb5, 0xd9, 0x11, 0x6e,
435            0xbc, 0x0a, 0x4e, 0x1f, 0x92, 0xf9, 0x10, 0xd5, 0x26, 0x02, 0x37, 0xfa, 0x45, 0xa9,
436            0x40, 0x8a, 0xad, 0x16,
437        ])
438        .unwrap();
439
440        let secp = bitcoin::secp256k1::Secp256k1::new();
441        let spend_pk = spend_sk.public_key(&secp);
442        let labels = LabelManager::new();
443
444        // Create a known tweak public key (just a deterministic pubkey)
445        let tweak_sk = bitcoin::secp256k1::SecretKey::from_slice(&[0x42; 32]).unwrap();
446        let tweak_pk = tweak_sk.public_key(&secp);
447
448        let candidates =
449            IndexServerBackend::derive_candidate_spks(&scan_sk, &spend_pk, &tweak_pk, &labels);
450
451        // With no labels, should produce exactly 1 candidate (base SPK at k=0)
452        assert_eq!(candidates.len(), 1);
453        assert!(candidates[0].1.is_none(), "base SPK should have no label");
454
455        // The SPK should be a P2TR script (34 bytes: OP_1 + 32-byte x-only key)
456        assert!(candidates[0].0.is_p2tr(), "candidate SPK should be P2TR");
457
458        // Verify determinism: same inputs produce same output
459        let candidates2 =
460            IndexServerBackend::derive_candidate_spks(&scan_sk, &spend_pk, &tweak_pk, &labels);
461        assert_eq!(
462            candidates[0].0, candidates2[0].0,
463            "derivation must be deterministic"
464        );
465    }
466
467    #[test]
468    fn derive_candidate_spks_with_labels() {
469        let secp = bitcoin::secp256k1::Secp256k1::new();
470
471        let scan_sk = ScanSecretKey::from_slice(&[
472            0xea, 0xdc, 0x78, 0x16, 0x5f, 0xf1, 0xf8, 0xea, 0x94, 0xad, 0x7c, 0xfd, 0xc5, 0x49,
473            0x90, 0x73, 0x8a, 0x4c, 0x53, 0xf6, 0xe0, 0x50, 0x7b, 0x42, 0x15, 0x42, 0x01, 0xb8,
474            0xe5, 0xdf, 0xf3, 0xb1,
475        ])
476        .unwrap();
477
478        let spend_sk = silent_payments_core::keys::SpendSecretKey::from_slice(&[
479            0x93, 0xf5, 0xed, 0x90, 0x7a, 0xd5, 0xb2, 0xbd, 0xbb, 0xdc, 0xb5, 0xd9, 0x11, 0x6e,
480            0xbc, 0x0a, 0x4e, 0x1f, 0x92, 0xf9, 0x10, 0xd5, 0x26, 0x02, 0x37, 0xfa, 0x45, 0xa9,
481            0x40, 0x8a, 0xad, 0x16,
482        ])
483        .unwrap();
484
485        let spend_pk = spend_sk.public_key(&secp);
486
487        let mut labels = LabelManager::new();
488        labels.add_label(&scan_sk, &spend_pk, 1, &secp).unwrap();
489        labels.add_label(&scan_sk, &spend_pk, 5, &secp).unwrap();
490
491        let tweak_sk = bitcoin::secp256k1::SecretKey::from_slice(&[0x42; 32]).unwrap();
492        let tweak_pk = tweak_sk.public_key(&secp);
493
494        let candidates =
495            IndexServerBackend::derive_candidate_spks(&scan_sk, &spend_pk, &tweak_pk, &labels);
496
497        // Should produce 1 base + 2 labeled = 3 candidates
498        assert_eq!(candidates.len(), 3);
499
500        // First should be the base (unlabeled)
501        assert!(
502            candidates[0].1.is_none(),
503            "first candidate should be unlabeled base SPK"
504        );
505
506        // All should be P2TR
507        for (spk, _) in &candidates {
508            assert!(spk.is_p2tr(), "all candidates should be P2TR");
509        }
510
511        // All SPKs should be different
512        let spk_set: std::collections::HashSet<Vec<u8>> = candidates
513            .iter()
514            .map(|(spk, _)| spk.as_bytes().to_vec())
515            .collect();
516        assert_eq!(spk_set.len(), 3, "all candidate SPKs must be distinct");
517    }
518
519    #[test]
520    fn hex_to_bytes_valid() {
521        assert_eq!(
522            hex_to_bytes("deadbeef").unwrap(),
523            vec![0xde, 0xad, 0xbe, 0xef]
524        );
525        assert_eq!(hex_to_bytes("00ff").unwrap(), vec![0x00, 0xff]);
526        assert_eq!(hex_to_bytes("").unwrap(), Vec::<u8>::new());
527    }
528
529    #[test]
530    fn hex_to_bytes_odd_length_errors() {
531        assert!(hex_to_bytes("abc").is_err());
532    }
533
534    #[test]
535    fn hex_to_bytes_invalid_chars_errors() {
536        assert!(hex_to_bytes("zzzz").is_err());
537    }
538
539    #[test]
540    fn parse_tweak_pubkey_valid() {
541        // A known valid compressed public key (33 bytes)
542        let secp = bitcoin::secp256k1::Secp256k1::new();
543        let sk = bitcoin::secp256k1::SecretKey::from_slice(&[0x42; 32]).unwrap();
544        let pk = sk.public_key(&secp);
545        let hex = pk.to_string();
546
547        let parsed = IndexServerBackend::parse_tweak_pubkey(&hex).unwrap();
548        assert_eq!(parsed, pk);
549    }
550
551    #[test]
552    fn parse_tweak_pubkey_invalid() {
553        let result = IndexServerBackend::parse_tweak_pubkey("not_a_pubkey");
554        assert!(result.is_err());
555    }
556
557    #[test]
558    fn scan_transaction_returns_unsupported_error() {
559        let backend = IndexServerBackend::new("http://localhost:8080");
560
561        let scan_sk = ScanSecretKey::from_slice(&[
562            0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
563            0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
564            0x1d, 0x1e, 0x1f, 0x20,
565        ])
566        .unwrap();
567
568        let spend_sk = silent_payments_core::keys::SpendSecretKey::from_slice(&[
569            0x93, 0xf5, 0xed, 0x90, 0x7a, 0xd5, 0xb2, 0xbd, 0xbb, 0xdc, 0xb5, 0xd9, 0x11, 0x6e,
570            0xbc, 0x0a, 0x4e, 0x1f, 0x92, 0xf9, 0x10, 0xd5, 0x26, 0x02, 0x37, 0xfa, 0x45, 0xa9,
571            0x40, 0x8a, 0xad, 0x16,
572        ])
573        .unwrap();
574
575        let secp = bitcoin::secp256k1::Secp256k1::new();
576        let spend_pk = spend_sk.public_key(&secp);
577        let labels = LabelManager::new();
578
579        let txid: Txid = "0000000000000000000000000000000000000000000000000000000000000000"
580            .parse()
581            .unwrap();
582
583        let result = backend.scan_transaction(&txid, &scan_sk, &spend_pk, &labels);
584        assert!(result.is_err());
585        let err = result.unwrap_err();
586        assert!(
587            matches!(err, ScanError::Backend(_)),
588            "should be Backend error, got: {:?}",
589            err
590        );
591        assert!(
592            err.to_string().contains("scan_transaction"),
593            "error message should mention scan_transaction"
594        );
595    }
596
597    #[test]
598    fn serde_tweak_response_deserializes() {
599        let json = r#"{"txid": "abc123", "tweak_data": "02deadbeef"}"#;
600        let resp: TweakResponse = serde_json::from_str(json).unwrap();
601        assert_eq!(resp.txid, "abc123");
602        assert_eq!(resp.tweak_data, "02deadbeef");
603    }
604
605    #[test]
606    fn serde_utxo_response_deserializes() {
607        let json =
608            r#"{"txid": "abc123", "vout": 0, "value": 50000, "scriptPubKey": "5120deadbeef"}"#;
609        let resp: UtxoResponse = serde_json::from_str(json).unwrap();
610        assert_eq!(resp.txid, "abc123");
611        assert_eq!(resp.vout, 0);
612        assert_eq!(resp.value, 50000);
613        assert_eq!(resp.script_pub_key, "5120deadbeef");
614    }
615
616    #[test]
617    fn serde_utxo_response_snake_case_alias() {
618        // Test with snake_case field name (some servers may use this)
619        let json = r#"{"txid": "abc123", "vout": 1, "value": 10000, "script_pub_key": "5120aabb"}"#;
620        let resp: UtxoResponse = serde_json::from_str(json).unwrap();
621        assert_eq!(resp.script_pub_key, "5120aabb");
622    }
623
624    #[test]
625    #[ignore = "requires running BIP0352 index server at localhost:8080"]
626    fn integration_scan_range_against_live_server() {
627        let backend = IndexServerBackend::new("http://localhost:8080");
628        let scan_sk = ScanSecretKey::from_slice(&[
629            0xea, 0xdc, 0x78, 0x16, 0x5f, 0xf1, 0xf8, 0xea, 0x94, 0xad, 0x7c, 0xfd, 0xc5, 0x49,
630            0x90, 0x73, 0x8a, 0x4c, 0x53, 0xf6, 0xe0, 0x50, 0x7b, 0x42, 0x15, 0x42, 0x01, 0xb8,
631            0xe5, 0xdf, 0xf3, 0xb1,
632        ])
633        .unwrap();
634
635        let spend_sk = silent_payments_core::keys::SpendSecretKey::from_slice(&[
636            0x93, 0xf5, 0xed, 0x90, 0x7a, 0xd5, 0xb2, 0xbd, 0xbb, 0xdc, 0xb5, 0xd9, 0x11, 0x6e,
637            0xbc, 0x0a, 0x4e, 0x1f, 0x92, 0xf9, 0x10, 0xd5, 0x26, 0x02, 0x37, 0xfa, 0x45, 0xa9,
638            0x40, 0x8a, 0xad, 0x16,
639        ])
640        .unwrap();
641
642        let secp = bitcoin::secp256k1::Secp256k1::new();
643        let spend_pk = spend_sk.public_key(&secp);
644        let labels = LabelManager::new();
645
646        let mut matches = Vec::new();
647        let mut progress = Vec::new();
648
649        let result = backend.scan_range(
650            800_000,
651            800_001,
652            &scan_sk,
653            &spend_pk,
654            &labels,
655            &mut |output| matches.push(output),
656            &mut |height| progress.push(height),
657        );
658
659        // At minimum, it should complete without error (server must be running)
660        assert!(result.is_ok(), "scan_range failed: {:?}", result);
661        assert_eq!(progress, vec![800_000, 800_001]);
662    }
663}