blvm-node 0.1.2

Bitcoin Commons BLVM: Minimal Bitcoin node implementation using blvm-protocol and blvm-consensus
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
//! Bandwidth-based peer scoring for IBD optimization
//!
//! Tracks peer performance metrics and scores peers for download selection.
//! Faster peers get more download work assigned.
//!
//! ## Sibling/LAN Node Support
//!
//! Automatically detects peers on private networks (LAN) and gives them
//! priority for block downloads. LAN peers typically have:
//! - <10ms latency vs 100-5000ms for internet peers
//! - ~1 Gbps throughput vs ~10-100 Mbps for internet
//! - 100% reliability vs connection drops
//!
//! This can speed up IBD by 10-50x when a local Bitcoin node is available.

use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
use std::sync::RwLock;
use std::time::{Duration, Instant};

/// LAN peer score bonus multiplier (maximum)
/// SECURITY: Reduced from 10x to 3x to prevent attack domination
/// See lan_security.rs for progressive trust system (1.5x -> 2x -> 3x)
const LAN_PEER_SCORE_MULTIPLIER: f64 = 3.0;

/// Check if an address is on a private/local network (LAN peer)
///
/// Detects:
/// - IPv4 private ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
/// - IPv4 loopback: 127.0.0.0/8
/// - IPv4 link-local: 169.254.0.0/16
/// - IPv6 unique local: fd00::/8 (ULA)
/// - IPv6 link-local: fe80::/10
/// - IPv6 loopback: ::1
pub fn is_lan_peer(addr: &SocketAddr) -> bool {
    match addr.ip() {
        IpAddr::V4(ip) => {
            ip.is_private() ||      // 10.x, 172.16-31.x, 192.168.x
            ip.is_loopback() ||     // 127.x
            ip.is_link_local() // 169.254.x
        }
        IpAddr::V6(ip) => {
            ip.is_loopback() ||     // ::1
            // Check for unique local (fd00::/8) or link-local (fe80::/10)
            {
                let segments = ip.segments();
                (segments[0] & 0xff00) == 0xfd00 ||  // ULA
                (segments[0] & 0xffc0) == 0xfe80     // Link-local
            }
        }
    }
}

/// Peer performance statistics for scoring
#[derive(Debug, Clone)]
pub struct PeerStats {
    /// Total bytes received from this peer
    pub bytes_received: u64,
    /// Total blocks received from this peer
    pub blocks_received: u64,
    /// Total time connected (seconds)
    pub connection_duration_secs: f64,
    /// Average block latency (milliseconds)
    pub avg_block_latency_ms: f64,
    /// Number of timeouts/failures
    pub failures: u64,
    /// Last successful block time
    pub last_block_time: Option<Instant>,
    /// Computed bandwidth (bytes/sec)
    pub bandwidth_bytes_per_sec: f64,
    /// Computed score (higher = better)
    pub score: f64,
    /// Whether this peer is on the local network (LAN/sibling)
    pub is_lan: bool,
}

impl Default for PeerStats {
    fn default() -> Self {
        Self {
            bytes_received: 0,
            blocks_received: 0,
            connection_duration_secs: 0.0,
            avg_block_latency_ms: 1000.0, // Default 1 second
            failures: 0,
            last_block_time: None,
            bandwidth_bytes_per_sec: 0.0,
            score: 1.0, // Start with neutral score
            is_lan: false,
        }
    }
}

impl PeerStats {
    /// Update bandwidth calculation
    pub fn update_bandwidth(&mut self) {
        if self.connection_duration_secs > 0.0 {
            self.bandwidth_bytes_per_sec =
                self.bytes_received as f64 / self.connection_duration_secs;
        }
    }

    /// Calculate composite score
    ///
    /// Score formula:
    /// - Base: bandwidth_bytes_per_sec / 100_000 (normalize to reasonable range)
    /// - Penalty: -0.1 per failure
    /// - Bonus: +0.2 for recent activity (< 30 seconds)
    /// - Latency penalty: -0.001 * avg_latency_ms (faster = better)
    /// - LAN bonus: 3x max multiplier for local network peers (reduced from 10x for security)
    pub fn calculate_score(&mut self) {
        // Before any blocks are downloaded, bandwidth is meaningless (only header
        // latency samples exist). Use the default initial score so that peers
        // registered during header sync aren't penalized vs unregistered peers.
        if self.blocks_received == 0 {
            self.score = if self.is_lan {
                1.0 * LAN_PEER_SCORE_MULTIPLIER
            } else {
                // Use latency hint: lower latency = better starting position

                if self.avg_block_latency_ms > 0.0 {
                    (1000.0 / self.avg_block_latency_ms.max(1.0)).min(2.0)
                } else {
                    1.0
                }
            };
            return;
        }

        let bandwidth_score = self.bandwidth_bytes_per_sec / 100_000.0;
        let failure_penalty = self.failures as f64 * 0.1;
        let activity_bonus = match self.last_block_time {
            Some(t) if t.elapsed() < Duration::from_secs(30) => 0.2,
            Some(t) if t.elapsed() < Duration::from_secs(60) => 0.1,
            _ => 0.0,
        };
        let latency_penalty = (self.avg_block_latency_ms / 1000.0) * 0.1;
        let base_score =
            (bandwidth_score - failure_penalty + activity_bonus - latency_penalty).max(0.1);

        self.score = if self.is_lan {
            base_score * LAN_PEER_SCORE_MULTIPLIER
        } else {
            base_score
        };
    }
}

/// Peer scoring manager for IBD optimization
pub struct PeerScorer {
    /// Per-peer statistics
    stats: RwLock<HashMap<SocketAddr, PeerStats>>,
    /// Start time for connection duration calculation
    start_time: Instant,
}

impl PeerScorer {
    /// Create a new peer scorer
    pub fn new() -> Self {
        Self {
            stats: RwLock::new(HashMap::new()),
            start_time: Instant::now(),
        }
    }

    /// Record bytes received from a peer
    pub fn record_bytes(&self, peer: SocketAddr, bytes: u64) {
        let mut stats = self.stats.write().unwrap();
        let entry = stats.entry(peer).or_insert_with(|| {
            let mut s = PeerStats::default();
            s.is_lan = is_lan_peer(&peer);
            s
        });
        entry.bytes_received += bytes;
        entry.connection_duration_secs = self.start_time.elapsed().as_secs_f64();
        entry.update_bandwidth();
        entry.calculate_score();
    }

    /// Record a block received from a peer
    pub fn record_block(&self, peer: SocketAddr, block_size: u64, latency_ms: f64) {
        let mut stats = self.stats.write().unwrap();
        let entry = stats.entry(peer).or_insert_with(|| {
            let mut s = PeerStats::default();
            s.is_lan = is_lan_peer(&peer);
            s
        });

        entry.blocks_received += 1;
        entry.bytes_received += block_size;
        entry.connection_duration_secs = self.start_time.elapsed().as_secs_f64();
        entry.last_block_time = Some(Instant::now());

        // Update average latency (exponential moving average)
        let alpha = 0.3; // Smoothing factor
        entry.avg_block_latency_ms =
            entry.avg_block_latency_ms * (1.0 - alpha) + latency_ms * alpha;

        entry.update_bandwidth();
        entry.calculate_score();
    }

    /// Record a latency sample (e.g. from header sync) — seeds peer ordering before block downloads.
    pub fn record_latency_sample(&self, peer: SocketAddr, latency_ms: f64) {
        let mut stats = self.stats.write().unwrap();
        let entry = stats.entry(peer).or_insert_with(|| {
            let mut s = PeerStats::default();
            s.is_lan = is_lan_peer(&peer);
            s
        });
        let alpha = 0.3;
        entry.avg_block_latency_ms =
            entry.avg_block_latency_ms * (1.0 - alpha) + latency_ms * alpha;
        entry.calculate_score();
    }

    /// Record a failure/timeout for a peer
    pub fn record_failure(&self, peer: SocketAddr) {
        let mut stats = self.stats.write().unwrap();
        let entry = stats.entry(peer).or_insert_with(|| {
            let mut s = PeerStats::default();
            s.is_lan = is_lan_peer(&peer);
            s
        });
        entry.failures += 1;
        entry.calculate_score();
    }

    /// Check if a peer is on the local network
    pub fn is_peer_lan(&self, peer: &SocketAddr) -> bool {
        self.stats
            .read()
            .unwrap()
            .get(peer)
            .map(|s| s.is_lan)
            .unwrap_or_else(|| is_lan_peer(peer))
    }

    /// Get count of LAN peers
    pub fn lan_peer_count(&self) -> usize {
        self.stats
            .read()
            .unwrap()
            .values()
            .filter(|s| s.is_lan)
            .count()
    }

    /// Get all LAN peers
    pub fn get_lan_peers(&self) -> Vec<SocketAddr> {
        self.stats
            .read()
            .unwrap()
            .iter()
            .filter(|(_, s)| s.is_lan)
            .map(|(addr, _)| *addr)
            .collect()
    }

    /// Get the score for a peer
    ///
    /// Applies LAN bonus even for peers without stats yet.
    /// This ensures LAN peers get priority at the START of IBD
    /// before any blocks are received and scored.
    pub fn get_score(&self, peer: &SocketAddr) -> f64 {
        self.stats
            .read()
            .unwrap()
            .get(peer)
            .map(|s| s.score)
            .unwrap_or_else(|| {
                // Apply LAN bonus to initial score for new peers
                if is_lan_peer(peer) {
                    1.0 * LAN_PEER_SCORE_MULTIPLIER // LAN peers start at 3.0 (reduced from 10.0)
                } else {
                    1.0 // Normal peers start at 1.0
                }
            })
    }

    /// Get stats for a peer
    pub fn get_stats(&self, peer: &SocketAddr) -> Option<PeerStats> {
        self.stats.read().unwrap().get(peer).cloned()
    }

    /// Get all peer scores sorted by score (highest first)
    pub fn get_sorted_peers(&self) -> Vec<(SocketAddr, f64)> {
        let stats = self.stats.read().unwrap();
        let mut peers: Vec<_> = stats.iter().map(|(addr, s)| (*addr, s.score)).collect();
        peers.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
        peers
    }

    /// Select the best N peers for download
    ///
    /// Uses weighted random selection based on scores, so faster peers
    /// are more likely to be selected but slower peers still get some work.
    pub fn select_best_peers(&self, available: &[SocketAddr], count: usize) -> Vec<SocketAddr> {
        if available.is_empty() {
            return vec![];
        }

        if available.len() <= count {
            return available.to_vec();
        }

        let stats = self.stats.read().unwrap();

        // Get scores for available peers
        let mut scored: Vec<_> = available
            .iter()
            .map(|addr| {
                let score = stats.get(addr).map(|s| s.score).unwrap_or(1.0);
                (*addr, score)
            })
            .collect();

        // Sort by score (highest first)
        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));

        // Take top N
        scored
            .into_iter()
            .take(count)
            .map(|(addr, _)| addr)
            .collect()
    }

    /// Get the single best peer for download
    pub fn get_best_peer(&self, available: &[SocketAddr]) -> Option<SocketAddr> {
        self.select_best_peers(available, 1).into_iter().next()
    }

    /// Get summary statistics for logging
    pub fn summary(&self) -> String {
        let stats = self.stats.read().unwrap();
        if stats.is_empty() {
            return "No peer stats yet".to_string();
        }

        let mut total_bytes = 0u64;
        let mut total_blocks = 0u64;
        let mut lan_blocks = 0u64;
        let mut lan_count = 0usize;
        let mut best_peer: Option<(SocketAddr, f64, bool)> = None;

        for (addr, s) in stats.iter() {
            total_bytes += s.bytes_received;
            total_blocks += s.blocks_received;
            if s.is_lan {
                lan_count += 1;
                lan_blocks += s.blocks_received;
            }
            if best_peer.is_none() || s.score > best_peer.as_ref().unwrap().1 {
                best_peer = Some((*addr, s.score, s.is_lan));
            }
        }

        let lan_pct = if total_blocks > 0 {
            (lan_blocks * 100) / total_blocks
        } else {
            0
        };

        format!(
            "Peers: {} ({} LAN), Total: {} blocks / {} MB, LAN blocks: {}%, Best: {:?} (score: {:.2}{})",
            stats.len(),
            lan_count,
            total_blocks,
            total_bytes / 1_000_000,
            lan_pct,
            best_peer.as_ref().map(|(addr, _, _)| addr),
            best_peer.as_ref().map(|(_, s, _)| *s).unwrap_or(0.0),
            if best_peer.as_ref().map(|(_, _, is_lan)| *is_lan).unwrap_or(false) { " LAN" } else { "" }
        )
    }
}

impl Default for PeerScorer {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_peer_scoring_basic() {
        let scorer = PeerScorer::new();
        let peer1: SocketAddr = "127.0.0.1:8333".parse().unwrap();
        let peer2: SocketAddr = "127.0.0.2:8333".parse().unwrap();

        // Peer1 gets more blocks faster
        scorer.record_block(peer1, 500_000, 100.0); // 500KB in 100ms
        scorer.record_block(peer1, 500_000, 100.0);
        scorer.record_block(peer1, 500_000, 100.0);

        // Peer2 gets fewer blocks slower
        scorer.record_block(peer2, 500_000, 500.0); // 500KB in 500ms
        scorer.record_failure(peer2);

        let score1 = scorer.get_score(&peer1);
        let score2 = scorer.get_score(&peer2);

        // Peer1 should have higher score
        assert!(
            score1 > score2,
            "Peer1 score {score1} should be > Peer2 score {score2}"
        );
    }

    #[test]
    fn test_peer_selection() {
        let scorer = PeerScorer::new();
        let peer1: SocketAddr = "127.0.0.1:8333".parse().unwrap();
        let peer2: SocketAddr = "127.0.0.2:8333".parse().unwrap();
        let peer3: SocketAddr = "127.0.0.3:8333".parse().unwrap();

        // Give peer1 best stats
        scorer.record_block(peer1, 1_000_000, 50.0);
        scorer.record_block(peer1, 1_000_000, 50.0);

        // Give peer2 medium stats
        scorer.record_block(peer2, 500_000, 200.0);

        // Give peer3 worst stats
        scorer.record_failure(peer3);
        scorer.record_failure(peer3);

        let available = vec![peer1, peer2, peer3];
        let selected = scorer.select_best_peers(&available, 2);

        // Should select peer1 and peer2 (not peer3)
        assert_eq!(selected.len(), 2);
        assert!(selected.contains(&peer1));
        assert!(selected.contains(&peer2));
        assert!(!selected.contains(&peer3));
    }

    // ============================================================
    // LAN Peer Detection Tests - Critical for sibling node prioritization
    // ============================================================

    #[test]
    fn test_is_lan_peer_ipv4_private_10_range() {
        // 10.0.0.0/8 - Class A private
        assert!(is_lan_peer(&"10.0.0.1:8333".parse().unwrap()));
        assert!(is_lan_peer(&"10.255.255.254:8333".parse().unwrap()));
        assert!(is_lan_peer(&"10.123.45.67:8333".parse().unwrap()));
    }

    #[test]
    fn test_is_lan_peer_ipv4_private_172_range() {
        // 172.16.0.0/12 - Class B private (172.16.x.x - 172.31.x.x)
        assert!(is_lan_peer(&"172.16.0.1:8333".parse().unwrap()));
        assert!(is_lan_peer(&"172.31.255.254:8333".parse().unwrap()));
        assert!(is_lan_peer(&"172.20.5.10:8333".parse().unwrap()));

        // 172.32.x.x should NOT be private
        assert!(!is_lan_peer(&"172.32.0.1:8333".parse().unwrap()));
        assert!(!is_lan_peer(&"172.15.0.1:8333".parse().unwrap()));
    }

    #[test]
    fn test_is_lan_peer_ipv4_private_192_168_range() {
        // 192.168.0.0/16 - Most common home network range
        assert!(is_lan_peer(&"192.168.0.1:8333".parse().unwrap()));
        assert!(is_lan_peer(&"192.168.1.1:8333".parse().unwrap()));
        assert!(is_lan_peer(&"192.168.2.100:8333".parse().unwrap())); // Start9 node!
        assert!(is_lan_peer(&"192.168.255.254:8333".parse().unwrap()));
    }

    #[test]
    fn test_is_lan_peer_ipv4_loopback() {
        // 127.0.0.0/8 - Loopback
        assert!(is_lan_peer(&"127.0.0.1:8333".parse().unwrap()));
        assert!(is_lan_peer(&"127.255.255.254:8333".parse().unwrap()));
    }

    #[test]
    fn test_is_lan_peer_ipv4_link_local() {
        // 169.254.0.0/16 - Link-local (APIPA)
        assert!(is_lan_peer(&"169.254.0.1:8333".parse().unwrap()));
        assert!(is_lan_peer(&"169.254.255.254:8333".parse().unwrap()));
    }

    #[test]
    fn test_is_lan_peer_ipv4_public() {
        // Public IPs should NOT be considered LAN peers
        assert!(!is_lan_peer(&"8.8.8.8:8333".parse().unwrap())); // Google DNS
        assert!(!is_lan_peer(&"1.1.1.1:8333".parse().unwrap())); // Cloudflare
        assert!(!is_lan_peer(&"45.33.20.159:8333".parse().unwrap())); // Random public
        assert!(!is_lan_peer(&"216.107.135.194:8333".parse().unwrap())); // Internet peer
    }

    #[test]
    fn test_is_lan_peer_ipv6_loopback() {
        // ::1 - IPv6 loopback
        assert!(is_lan_peer(&"[::1]:8333".parse().unwrap()));
    }

    #[test]
    fn test_is_lan_peer_ipv6_unique_local() {
        // fd00::/8 - Unique Local Address (ULA)
        assert!(is_lan_peer(&"[fd00::1]:8333".parse().unwrap()));
        assert!(is_lan_peer(&"[fd12:3456:789a::1]:8333".parse().unwrap()));
        assert!(is_lan_peer(
            &"[fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff]:8333"
                .parse()
                .unwrap()
        ));
    }

    #[test]
    fn test_is_lan_peer_ipv6_link_local() {
        // fe80::/10 - Link-local
        assert!(is_lan_peer(&"[fe80::1]:8333".parse().unwrap()));
        assert!(is_lan_peer(&"[fe80::abcd:1234]:8333".parse().unwrap()));
    }

    #[test]
    fn test_is_lan_peer_ipv6_public() {
        // Public IPv6 should NOT be LAN
        assert!(!is_lan_peer(
            &"[2001:4860:4860::8888]:8333".parse().unwrap()
        )); // Google DNS
        assert!(!is_lan_peer(
            &"[2606:4700:4700::1111]:8333".parse().unwrap()
        )); // Cloudflare
    }

    // ============================================================
    // LAN Bonus Score Tests - Critical for sibling node prioritization
    // ============================================================

    #[test]
    fn test_lan_peer_gets_10x_score_multiplier() {
        let scorer = PeerScorer::new();

        // LAN peer (192.168.x.x)
        let lan_peer: SocketAddr = "192.168.2.100:8333".parse().unwrap();
        // Internet peer (public IP)
        let internet_peer: SocketAddr = "45.33.20.159:8333".parse().unwrap();

        // Record identical performance for both
        scorer.record_block(lan_peer, 1_000_000, 100.0);
        scorer.record_block(internet_peer, 1_000_000, 100.0);

        let lan_score = scorer.get_score(&lan_peer);
        let internet_score = scorer.get_score(&internet_peer);

        // LAN peer should have higher score due to multiplier (3x for security)
        assert!(
            lan_score > internet_score * 2.0,
            "LAN peer score {lan_score} should be significantly higher than internet peer {internet_score} (3x multiplier expected)"
        );

        // Verify LAN detection worked
        assert!(
            scorer.is_peer_lan(&lan_peer),
            "192.168.2.100 should be detected as LAN"
        );
        assert!(
            !scorer.is_peer_lan(&internet_peer),
            "45.33.20.159 should NOT be detected as LAN"
        );
    }

    #[test]
    fn test_lan_peer_dominates_best_peer_selection() {
        let scorer = PeerScorer::new();

        // LAN peer with moderate performance
        let lan_peer: SocketAddr = "192.168.1.50:8333".parse().unwrap();
        scorer.record_block(lan_peer, 1_000_000, 80.0); // 1MB, 80ms

        // Internet peer with slightly better raw performance (but no LAN bonus)
        let internet_peer: SocketAddr = "8.8.8.8:8333".parse().unwrap();
        scorer.record_block(internet_peer, 1_200_000, 70.0); // 1.2MB, 70ms - ~1.5x better

        let available = vec![lan_peer, internet_peer];
        let best = scorer.get_best_peer(&available);

        // LAN peer should still win due to 3x multiplier
        assert_eq!(
            best,
            Some(lan_peer),
            "LAN peer should be selected as best despite worse raw performance"
        );
    }

    #[test]
    fn test_lan_peer_count() {
        let scorer = PeerScorer::new();

        // Add some LAN peers
        scorer.record_block("192.168.1.1:8333".parse().unwrap(), 100, 10.0);
        scorer.record_block("192.168.2.100:8333".parse().unwrap(), 100, 10.0);
        scorer.record_block("10.0.0.5:8333".parse().unwrap(), 100, 10.0);

        // Add some internet peers
        scorer.record_block("8.8.8.8:8333".parse().unwrap(), 100, 10.0);
        scorer.record_block("1.1.1.1:8333".parse().unwrap(), 100, 10.0);

        assert_eq!(scorer.lan_peer_count(), 3, "Should have 3 LAN peers");

        let lan_peers = scorer.get_lan_peers();
        assert_eq!(lan_peers.len(), 3, "get_lan_peers should return 3 peers");
    }

    // ============================================================
    // Failure Penalty Tests
    // ============================================================

    #[test]
    fn test_failures_reduce_score() {
        let scorer = PeerScorer::new();
        let peer: SocketAddr = "8.8.8.8:8333".parse().unwrap();

        // Record good performance
        scorer.record_block(peer, 1_000_000, 100.0);
        let score_before = scorer.get_score(&peer);

        // Record failures
        scorer.record_failure(peer);
        scorer.record_failure(peer);
        scorer.record_failure(peer);

        let score_after = scorer.get_score(&peer);

        assert!(
            score_after < score_before,
            "Score should decrease after failures: {score_before} -> {score_after}"
        );
    }

    #[test]
    fn test_minimum_score_preserved() {
        let scorer = PeerScorer::new();
        let peer: SocketAddr = "8.8.8.8:8333".parse().unwrap();

        // Record many failures
        for _ in 0..100 {
            scorer.record_failure(peer);
        }

        let score = scorer.get_score(&peer);

        // Score should not go below 0.1
        assert!(
            score >= 0.1,
            "Score should not go below minimum 0.1: got {score}"
        );
    }
}