1use crate::{
7 Error,
8 peer_address::{is_circuit, parse, relay},
9 peer_gossip::{is_public, validate_gossip_address},
10 peer_protocol::GossipPeer,
11};
12use bitcoin::BlockHash;
13use libp2p::{Multiaddr, PeerId, multiaddr::Protocol};
14use rand::seq::SliceRandom;
15use serde::{Deserialize, Serialize};
16use std::{cmp::Reverse, fs, io::ErrorKind, net::IpAddr, path::Path};
17use urma_node_client::model::BookEntry as ListedEntry;
18
19pub use crate::peer_address::PeerAddress;
20pub use urma_node_client::model::BookOrigin as Origin;
21
22#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct BookEntry {
27 pub peer_id: Option<PeerId>,
28 pub addrs: Vec<Multiaddr>,
29 pub origin: Origin,
30 pub last_success: Option<u64>,
31 pub failures: u32,
32 pub banned_until: Option<u64>,
33}
34
35impl BookEntry {
36 pub fn circuit_only(&self) -> bool {
38 self.addrs.iter().all(is_circuit)
39 }
40}
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
45pub struct BookStats {
46 pub seeds: u64,
47 pub book: u64,
48 pub reached: u64,
49 pub reached_ever: u64,
50 pub gossip: u64,
51}
52
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56enum Learned {
57 Operator,
58 Gossip,
59 Identify,
60}
61
62pub struct PeerBook {
63 chain_genesis: BlockHash,
64 entries: Vec<BookEntry>,
65}
66
67#[derive(Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69struct SavedBook {
70 version: u32,
71 chain_genesis: String,
72 entries: Vec<SavedEntry>,
73}
74
75#[derive(Serialize, Deserialize)]
77#[serde(deny_unknown_fields)]
78struct SavedEntry {
79 peer_id: Option<String>,
80 addrs: Vec<String>,
81 origin: Origin,
82 last_success: Option<u64>,
83 failures: u32,
84 banned_until: Option<u64>,
85}
86
87impl From<SavedEntry> for ListedEntry {
88 fn from(saved: SavedEntry) -> Self {
89 let SavedEntry {
90 peer_id,
91 addrs,
92 origin,
93 last_success,
94 failures,
95 banned_until,
96 } = saved;
97 Self {
98 peer_id,
99 addrs,
100 origin,
101 last_success,
102 failures,
103 banned_until,
104 }
105 }
106}
107
108enum Host {
110 Ip(IpAddr),
111 Name,
112}
113
114impl PeerBook {
115 pub const CAPACITY: usize = 1024;
116 pub const CIRCUIT_CAPACITY: usize = 256;
118 pub const ADDRESSES: usize = 4;
119 pub const GROUP_LIMIT: usize = 2;
121 const FILE_LIMIT: usize = 1024 * 1024;
122 const VERSION: u32 = 2;
123 const BACKOFF_SECONDS: u64 = 60;
124 const BACKOFF_CAP: u64 = 86_400;
125 pub const SEED_BACKOFF_CAP: u64 = 600;
128
129 pub fn empty(chain_genesis: BlockHash) -> Self {
130 Self {
131 chain_genesis,
132 entries: Vec::new(),
133 }
134 }
135
136 pub fn load(path: &Path, chain_genesis: BlockHash) -> Result<Self, Error> {
140 let mut book = Self::empty(chain_genesis);
141 let metadata = match fs::symlink_metadata(path) {
142 Ok(metadata) => metadata,
143 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(book),
144 Err(error) => return Err(error.into()),
145 };
146 let limit = u64::try_from(Self::FILE_LIMIT).map_err(|e| Error::Invalid(e.to_string()))?;
147 if !metadata.is_file() || metadata.len() > limit {
148 return Err(Error::Invalid(
149 "peer book must be a regular file of at most 1 MiB".into(),
150 ));
151 }
152 let bytes = urma_io::read_regular(path, Self::FILE_LIMIT).map_err(io_source)?;
153 let saved: SavedBook = serde_json::from_slice(&bytes)?;
154 if saved.version != Self::VERSION {
155 return Err(Error::Invalid("unsupported peer book version".into()));
156 }
157 let saved_genesis: BlockHash = saved
158 .chain_genesis
159 .parse()
160 .map_err(|_| Error::Invalid("invalid peer book chain genesis".into()))?;
161 if saved_genesis != chain_genesis {
162 return Err(Error::Invalid("peer book belongs to another chain".into()));
163 }
164 if saved.entries.len() > Self::CAPACITY {
165 return Err(Error::Busy("peer book has too many entries".into()));
166 }
167 for saved in saved.entries {
168 let entry = restore(saved)?;
169 if book.overlaps(&entry) {
170 return Err(Error::Invalid("duplicate saved peer or address".into()));
171 }
172 book.entries.push(entry);
173 }
174 book.trim_circuits();
175 Ok(book)
176 }
177
178 fn trim_circuits(&mut self) {
181 while self.circuit_entries() > Self::CIRCUIT_CAPACITY {
182 let Some(worst) = self
183 .entries
184 .iter()
185 .enumerate()
186 .filter(|(_, entry)| entry.circuit_only())
187 .max_by_key(|(_, entry)| (entry.failures, Reverse(entry.last_success)))
188 .map(|(index, _)| index)
189 else {
190 return;
191 };
192 self.entries.remove(worst);
193 }
194 }
195
196 pub fn save(&self, path: &Path) -> Result<(), Error> {
198 let mut entries: Vec<SavedEntry> = self.entries.iter().map(persist).collect();
199 entries.sort_by(|a, b| (&a.peer_id, &a.addrs).cmp(&(&b.peer_id, &b.addrs)));
200 let saved = SavedBook {
201 version: Self::VERSION,
202 chain_genesis: self.chain_genesis.to_string(),
203 entries,
204 };
205 let bytes = serde_json::to_vec(&saved)?;
206 if bytes.len() > Self::FILE_LIMIT {
207 return Err(Error::Invalid("peer book exceeds 1 MiB".into()));
208 }
209 urma_io::write_replace(path, &bytes).map_err(io_source)
210 }
211
212 pub fn entries(&self) -> &[BookEntry] {
213 &self.entries
214 }
215
216 pub fn add(&mut self, address: Multiaddr, origin: Origin) -> Result<bool, Error> {
223 let learned = match origin {
224 Origin::Gossip => Learned::Gossip,
225 Origin::Seed | Origin::Manual => Learned::Operator,
226 };
227 self.insert(address, origin, learned)
228 }
229
230 fn insert(
234 &mut self,
235 address: Multiaddr,
236 origin: Origin,
237 learned: Learned,
238 ) -> Result<bool, Error> {
239 let (address, peer) = split(address)?;
240 let circuit = is_circuit(&address);
241 if circuit && (origin != Origin::Gossip || peer.is_none()) {
242 return Err(Error::Invalid(
243 "circuit addresses come only from gossip and name their peer".into(),
244 ));
245 }
246 if origin == Origin::Gossip {
247 self.admit_gossip(&address)?;
248 }
249 if let Some(index) = self.position_of_address(&address, peer) {
250 if learned != Learned::Operator {
251 return Ok(false);
252 }
253 let entry = &mut self.entries[index];
254 if peer.is_some() && entry.peer_id.is_some() && entry.peer_id != peer {
255 return Err(Error::Invalid("address already names another peer".into()));
256 }
257 let upgraded = origin > entry.origin || (entry.peer_id.is_none() && peer.is_some());
258 entry.origin = entry.origin.max(origin);
259 entry.peer_id = entry.peer_id.or(peer);
260 return Ok(upgraded);
261 }
262 if let Some(index) = peer.and_then(|peer| self.position_of_peer(peer)) {
263 if learned == Learned::Gossip {
264 return Ok(false);
265 }
266 let entry = &mut self.entries[index];
267 if entry.addrs.len() == Self::ADDRESSES {
268 return Err(Error::Busy("peer already has four addresses".into()));
269 }
270 entry.addrs.push(address);
271 entry.origin = entry.origin.max(origin);
272 return Ok(true);
273 }
274 self.make_room(origin, circuit)?;
275 self.entries.push(BookEntry {
276 peer_id: peer,
277 addrs: vec![address],
278 origin,
279 last_success: None,
280 failures: 0,
281 banned_until: None,
282 });
283 Ok(true)
284 }
285
286 pub fn merge(&mut self, peers: &[GossipPeer], local: PeerId, omitting: &[String]) -> usize {
290 self.learn(peers, local, omitting, Learned::Gossip)
291 }
292
293 pub fn merge_identified(
296 &mut self,
297 peers: &[GossipPeer],
298 local: PeerId,
299 omitting: &[String],
300 ) -> usize {
301 self.learn(peers, local, omitting, Learned::Identify)
302 }
303
304 fn learn(
305 &mut self,
306 peers: &[GossipPeer],
307 local: PeerId,
308 omitting: &[String],
309 learned: Learned,
310 ) -> usize {
311 let mut changed = 0;
312 for peer in peers {
313 let id = peer.peer_id;
314 if id == local || omitting.contains(&id.to_string()) {
315 continue;
316 }
317 let addresses = own_addresses(peer, local);
318 if self.position_of_peer(id).is_none() {
319 changed += self.insert_learned_peer(id, addresses);
320 continue;
321 }
322 if learned != Learned::Identify {
323 continue;
324 }
325 for address in addresses {
326 let address = address.with(Protocol::P2p(id));
327 if let Ok(true) = self.insert(address, Origin::Gossip, learned) {
328 changed += 1;
329 }
330 }
331 }
332 changed
333 }
334
335 fn insert_learned_peer(&mut self, id: PeerId, addresses: Vec<Multiaddr>) -> usize {
338 let mut admitted = Vec::new();
339 for address in addresses {
340 let known = self.position_of_address(&address, Some(id)).is_some();
341 if !known && self.admit_gossip(&address).is_ok() {
342 admitted.push(address);
343 }
344 }
345 let circuit = admitted.iter().all(is_circuit);
346 if admitted.is_empty() || self.make_room(Origin::Gossip, circuit).is_err() {
347 return 0;
348 }
349 let count = admitted.len();
350 self.entries.push(BookEntry {
351 peer_id: Some(id),
352 addrs: admitted,
353 origin: Origin::Gossip,
354 last_success: None,
355 failures: 0,
356 banned_until: None,
357 });
358 count
359 }
360
361 pub fn succeeded(&mut self, address: &Multiaddr, peer: PeerId, local: PeerId, now: u64) {
366 let Ok((address, named)) = split(address.clone()) else {
367 return;
368 };
369 let Some(index) = self.position_of_address(&address, named) else {
370 return;
371 };
372 if peer == local {
373 self.entries.remove(index);
374 return;
375 }
376 let learnable = matches!(host(&address), Some(Host::Ip(_)));
377 let known_elsewhere = self
378 .position_of_peer(peer)
379 .is_some_and(|other| other != index);
380 let entry = &mut self.entries[index];
381 entry.last_success = Some(now);
382 entry.failures = 0;
383 entry.banned_until = None;
384 if entry.peer_id.is_none() && learnable && !known_elsewhere {
385 entry.peer_id = Some(peer);
386 }
387 }
388
389 pub fn failed(&mut self, address: &Multiaddr, now: u64) {
393 let Ok((address, named)) = split(address.clone()) else {
394 return;
395 };
396 let Some(index) = self.position_of_address(&address, named) else {
397 return;
398 };
399 let addrs = &mut self.entries[index].addrs;
400 addrs.retain(|known| *known != address);
401 addrs.push(address);
402 self.fail_entry(index, now);
403 }
404
405 pub fn failed_peer(&mut self, peer: PeerId, now: u64) -> bool {
408 let Some(index) = self.position_of_peer(peer) else {
409 return false;
410 };
411 self.fail_entry(index, now);
412 true
413 }
414
415 fn fail_entry(&mut self, index: usize, now: u64) {
416 let entry = &mut self.entries[index];
417 entry.failures = entry.failures.checked_add(1).unwrap_or(u32::MAX);
418 let cap = match entry.origin {
419 Origin::Seed => Self::SEED_BACKOFF_CAP,
420 Origin::Gossip | Origin::Manual => Self::BACKOFF_CAP,
421 };
422 let delay = 2u64
423 .checked_pow(entry.failures - 1)
424 .and_then(|factor| factor.checked_mul(Self::BACKOFF_SECONDS))
425 .map_or(cap, |delay| delay.min(cap));
426 entry.banned_until = Some(now.checked_add(delay).unwrap_or(u64::MAX));
427 }
428
429 pub fn candidates(
435 &self,
436 now: u64,
437 connected: &[PeerId],
438 omitting: &[String],
439 count: usize,
440 ) -> Vec<Multiaddr> {
441 let eligible = |entry: &&BookEntry| {
442 entry.origin != Origin::Seed
443 && entry.banned_until.is_none_or(|until| until <= now)
444 && entry.peer_id.is_none_or(|peer| {
445 !connected.contains(&peer) && !omitting.contains(&peer.to_string())
446 })
447 };
448 let (circuit, direct): (Vec<&BookEntry>, Vec<&BookEntry>) = self
449 .entries
450 .iter()
451 .filter(eligible)
452 .partition(|entry| entry.circuit_only());
453 let mut picked = alternate(direct);
454 picked.extend(alternate(circuit));
455 picked.truncate(count);
456 picked.into_iter().filter_map(dial_address).collect()
457 }
458
459 pub fn seeds(&self, omitting: &[String], now: Option<u64>) -> Vec<Multiaddr> {
463 let ready = |until: u64, now: u64| until <= now || until - now > Self::SEED_BACKOFF_CAP;
464 let mut seeds: Vec<Multiaddr> = self
465 .entries
466 .iter()
467 .filter(|entry| entry.origin == Origin::Seed)
468 .filter(|entry| {
469 now.is_none_or(|now| entry.banned_until.is_none_or(|until| ready(until, now)))
470 })
471 .filter(|entry| {
472 entry
473 .peer_id
474 .is_none_or(|peer| !omitting.contains(&peer.to_string()))
475 })
476 .filter_map(dial_address)
477 .collect();
478 seeds.shuffle(&mut rand::thread_rng());
479 seeds
480 }
481
482 pub fn sync_addresses(&self, omitting: &[String]) -> Vec<PeerAddress> {
486 self.entries
487 .iter()
488 .filter_map(|entry| {
489 let peer = entry.peer_id?;
490 if omitting.contains(&peer.to_string()) {
491 return None;
492 }
493 entry.addrs.iter().find_map(|address| {
494 PeerAddress::new(peer, address.clone().with(Protocol::P2p(peer))).ok()
495 })
496 })
497 .collect()
498 }
499
500 pub fn gossip(&self, limit: usize, omitting: &[String]) -> Vec<GossipPeer> {
504 let mut verified: Vec<(&BookEntry, PeerId, u64)> = self
505 .entries
506 .iter()
507 .filter_map(|entry| Some((entry, entry.peer_id?, entry.last_success?)))
508 .filter(|(_, peer, _)| !omitting.contains(&peer.to_string()))
509 .collect();
510 verified.sort_by_key(|(_, _, seen)| std::cmp::Reverse(*seen));
511 verified
512 .into_iter()
513 .filter_map(|(entry, peer_id, last_seen)| {
514 let addrs: Vec<Multiaddr> =
515 entry.addrs.iter().filter(|a| public(a)).cloned().collect();
516 (!addrs.is_empty()).then_some(GossipPeer {
517 peer_id,
518 addrs,
519 last_seen,
520 })
521 })
522 .take(limit)
523 .collect()
524 }
525
526 pub fn stats(&self) -> BookStats {
527 let count = |keep: &dyn Fn(&BookEntry) -> bool| {
528 u64::try_from(self.entries.iter().filter(|entry| keep(entry)).count())
529 .unwrap_or(u64::MAX)
530 };
531 BookStats {
532 seeds: count(&|entry| entry.origin == Origin::Seed),
533 book: count(&|_| true),
534 reached: count(&|entry| entry.failures == 0 && entry.last_success.is_some()),
535 reached_ever: count(&|entry| entry.last_success.is_some()),
536 gossip: count(&|entry| entry.origin == Origin::Gossip),
537 }
538 }
539
540 pub fn listing(&self) -> Vec<ListedEntry> {
542 let mut entries: Vec<ListedEntry> = self
543 .entries
544 .iter()
545 .map(|entry| persist(entry).into())
546 .collect();
547 entries.sort_by(|a, b| (&a.peer_id, &a.addrs).cmp(&(&b.peer_id, &b.addrs)));
548 entries.truncate(Self::CAPACITY);
549 entries
550 }
551
552 pub fn remove(&mut self, peer: PeerId) -> bool {
554 let before = self.entries.len();
555 self.entries.retain(|entry| entry.peer_id != Some(peer));
556 self.entries.len() != before
557 }
558
559 fn position_of_address(&self, address: &Multiaddr, peer: Option<PeerId>) -> Option<usize> {
562 let circuit = is_circuit(address);
563 self.entries
564 .iter()
565 .position(|entry| entry.addrs.contains(address) && (!circuit || entry.peer_id == peer))
566 }
567
568 fn position_of_peer(&self, peer: PeerId) -> Option<usize> {
569 self.entries
570 .iter()
571 .position(|entry| entry.peer_id == Some(peer))
572 }
573
574 fn circuit_entries(&self) -> usize {
575 self.entries
576 .iter()
577 .filter(|entry| entry.circuit_only())
578 .count()
579 }
580
581 fn overlaps(&self, candidate: &BookEntry) -> bool {
582 candidate
583 .peer_id
584 .is_some_and(|peer| self.position_of_peer(peer).is_some())
585 || candidate.addrs.iter().any(|address| {
586 self.position_of_address(address, candidate.peer_id)
587 .is_some()
588 })
589 }
590
591 fn admit_gossip(&self, address: &Multiaddr) -> Result<(), Error> {
595 let Some(Host::Ip(ip)) = host(address) else {
596 return Err(Error::Invalid(
597 "gossip addresses must be literal IPs".into(),
598 ));
599 };
600 validate_gossip_address(address)?;
601 if !is_public(address) {
602 return Err(Error::Invalid("gossip named a non-public address".into()));
603 }
604 if is_circuit(address) {
605 return Ok(());
606 }
607 let neighbours = self
608 .entries
609 .iter()
610 .filter(|entry| entry.origin == Origin::Gossip)
611 .filter(|entry| entry.addrs.iter().any(|known| same_group(known, ip)))
612 .count();
613 if neighbours >= Self::GROUP_LIMIT && self.position_of_address(address, None).is_none() {
614 return Err(Error::Busy("gossip network group is full".into()));
615 }
616 Ok(())
617 }
618
619 fn make_room(&mut self, origin: Origin, circuit: bool) -> Result<(), Error> {
625 let crowded = circuit && self.circuit_entries() >= Self::CIRCUIT_CAPACITY;
626 if !crowded && self.entries.len() < Self::CAPACITY {
627 return Ok(());
628 }
629 let worst = self
630 .entries
631 .iter()
632 .enumerate()
633 .filter(|(_, entry)| entry.origin == Origin::Gossip)
634 .filter(|(_, entry)| !crowded || entry.circuit_only())
635 .max_by_key(|(_, entry)| {
636 (
637 entry.failures,
638 std::cmp::Reverse(entry.last_success.unwrap_or(0)),
639 )
640 })
641 .map(|(index, _)| index);
642 match worst {
643 Some(index)
644 if origin != Origin::Gossip
645 || self.entries[index].failures > 0
646 || self.entries[index].last_success.is_none() =>
647 {
648 self.entries.remove(index);
649 Ok(())
650 }
651 _ => Err(Error::Busy("peer book is full".into())),
652 }
653 }
654}
655
656fn own_addresses(peer: &GossipPeer, local: PeerId) -> Vec<Multiaddr> {
659 let id = peer.peer_id;
660 let mut addresses = Vec::new();
661 for address in peer.addrs.iter().take(PeerBook::ADDRESSES) {
662 if relay(address).is_some_and(|relay| relay == id || relay == local) {
663 continue;
664 }
665 let Ok((address, named)) = split(address.clone()) else {
666 continue;
667 };
668 if named.is_none_or(|named| named == id) && !addresses.contains(&address) {
669 addresses.push(address);
670 }
671 }
672 addresses
673}
674
675fn alternate(entries: Vec<&BookEntry>) -> Vec<&BookEntry> {
678 let (mut fresh, mut tried): (Vec<&BookEntry>, Vec<&BookEntry>) = entries
679 .into_iter()
680 .partition(|entry| entry.last_success.is_none() && entry.failures == 0);
681 fresh.shuffle(&mut rand::thread_rng());
682 tried.sort_by_key(|entry| (entry.banned_until, entry.failures));
683 let mut fresh = fresh.into_iter();
684 let mut tried = tried.into_iter();
685 let mut picked = Vec::new();
686 loop {
687 let (next_fresh, next_tried) = (fresh.next(), tried.next());
688 if next_fresh.is_none() && next_tried.is_none() {
689 return picked;
690 }
691 picked.extend(next_fresh.into_iter().chain(next_tried));
692 }
693}
694
695fn split(address: Multiaddr) -> Result<(Multiaddr, Option<PeerId>), Error> {
697 let shape = parse(&address)?;
698 Ok((shape.bare, shape.peer))
699}
700
701fn host(address: &Multiaddr) -> Option<Host> {
702 match address.iter().next()? {
703 Protocol::Ip4(ip) => Some(Host::Ip(IpAddr::V4(ip))),
704 Protocol::Ip6(ip) => Some(Host::Ip(IpAddr::V6(ip))),
705 Protocol::Dns(_) | Protocol::Dns4(_) | Protocol::Dns6(_) | Protocol::Dnsaddr(_) => {
706 Some(Host::Name)
707 }
708 _ => None,
709 }
710}
711
712fn public(address: &Multiaddr) -> bool {
715 matches!(host(address), Some(Host::Ip(_))) && is_public(address)
716}
717
718fn same_group(address: &Multiaddr, ip: IpAddr) -> bool {
720 match (host(address), ip) {
721 (Some(Host::Ip(IpAddr::V4(known))), IpAddr::V4(ip)) => {
722 known.octets()[..3] == ip.octets()[..3]
723 }
724 (Some(Host::Ip(IpAddr::V6(known))), IpAddr::V6(ip)) => {
725 known.segments()[..3] == ip.segments()[..3]
726 }
727 _ => false,
728 }
729}
730
731fn dial_address(entry: &BookEntry) -> Option<Multiaddr> {
733 let address = entry
734 .addrs
735 .iter()
736 .find(|address| !is_circuit(address))
737 .or(entry.addrs.first())?
738 .clone();
739 Some(match entry.peer_id {
740 Some(peer) => address.with(Protocol::P2p(peer)),
741 None => address,
742 })
743}
744
745fn persist(entry: &BookEntry) -> SavedEntry {
746 SavedEntry {
747 peer_id: entry.peer_id.map(|peer| peer.to_string()),
748 addrs: entry.addrs.iter().map(Multiaddr::to_string).collect(),
749 origin: entry.origin,
750 last_success: entry.last_success,
751 failures: entry.failures,
752 banned_until: entry.banned_until,
753 }
754}
755
756fn restore(saved: SavedEntry) -> Result<BookEntry, Error> {
757 let peer_id = saved
758 .peer_id
759 .map(|peer| peer.parse::<PeerId>())
760 .transpose()
761 .map_err(|_| Error::Invalid("invalid saved peer ID".into()))?;
762 if saved.addrs.is_empty() || saved.addrs.len() > PeerBook::ADDRESSES {
763 return Err(Error::Invalid(
764 "saved peer needs one to four addresses".into(),
765 ));
766 }
767 let mut addrs = Vec::new();
768 for address in saved.addrs {
769 let parsed: Multiaddr = address
770 .parse()
771 .map_err(|_| Error::Invalid("invalid saved peer address".into()))?;
772 let (bare, named) = split(parsed)?;
773 if named.is_some() || addrs.contains(&bare) {
774 return Err(Error::Invalid("saved peer address is not canonical".into()));
775 }
776 if saved.origin == Origin::Gossip && !public(&bare) {
777 return Err(Error::Invalid("saved gossip address is not public".into()));
778 }
779 if is_circuit(&bare) && (saved.origin != Origin::Gossip || peer_id.is_none()) {
780 return Err(Error::Invalid(
781 "saved circuit address needs a gossip entry with its peer".into(),
782 ));
783 }
784 addrs.push(bare);
785 }
786 Ok(BookEntry {
787 peer_id,
788 addrs,
789 origin: saved.origin,
790 last_success: saved.last_success,
791 failures: saved.failures,
792 banned_until: saved.banned_until,
793 })
794}
795
796fn io_source(error: urma_io::Error) -> Error {
797 Error::Source(Box::new(error))
798}