1use 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#[derive(Debug, Deserialize)]
40struct TweakResponse {
41 txid: String,
43 tweak_data: String,
45}
46
47#[derive(Debug, Deserialize)]
49struct UtxoResponse {
50 txid: String,
52 vout: u32,
54 value: u64,
56 #[serde(alias = "scriptPubKey")]
58 script_pub_key: String,
59}
60
61pub struct IndexServerBackend {
82 base_url: String,
84 auth_token: Option<String>,
86}
87
88impl IndexServerBackend {
89 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 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 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 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 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 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 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 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 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 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 #[cfg(test)]
209 fn base_url(&self) -> &str {
210 &self.base_url
211 }
212
213 #[cfg(test)]
215 fn auth_token(&self) -> Option<&str> {
216 self.auth_token.as_deref()
217 }
218}
219
220fn 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 let tweaks: Vec<TweakResponse> = self.fetch_json(&format!("/tweaks/{height}"))?;
248
249 let utxos: Vec<UtxoResponse> = self.fetch_json(&format!("/utxos/{height}"))?;
251
252 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 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 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 candidates.clone()
287 } else {
288 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 ¤t_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 if *utxo_txid != tweak_txid {
320 continue;
321 }
322
323 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 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 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 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 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 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 assert_eq!(candidates.len(), 1);
453 assert!(candidates[0].1.is_none(), "base SPK should have no label");
454
455 assert!(candidates[0].0.is_p2tr(), "candidate SPK should be P2TR");
457
458 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 assert_eq!(candidates.len(), 3);
499
500 assert!(
502 candidates[0].1.is_none(),
503 "first candidate should be unlabeled base SPK"
504 );
505
506 for (spk, _) in &candidates {
508 assert!(spk.is_p2tr(), "all candidates should be P2TR");
509 }
510
511 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 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 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 assert!(result.is_ok(), "scan_range failed: {:?}", result);
661 assert_eq!(progress, vec![800_000, 800_001]);
662 }
663}