Repository: ergoplatform/sigma-rust (language: rust)

Issue:
[200 SigmaUSD] Peer management
Create the following types in ergo-p2p crate:
- `PeerSpec`
Ergo node implementation - https://github.com/ergoplatform/ergo/blob/95139ec990fc91f8f4c70a61468edf8834143329/src/main/scala/scorex/core/network/PeerSpec.scala#L11-L27

- `PeerInfo`
Ergo node implementation - https://github.com/ergoplatform/ergo/blob/95139ec990fc91f8f4c70a61468edf8834143329/src/main/scala/scorex/core/network/peer/PeerInfo.scala#L8-L17

Implement serialization for the types above by implementing `ScorexSerializable` trait from sigma-ser (after #477 is implemented).

Create `PeerDatabase` trait and `InMemoryPeerDatabase` implementation with the following methods:
- `get_peer_by_addr`;
- `add_or_update_peer`;
- `remove_peer`;
- `known_peers`;

See Ergo node implementation - https://github.com/ergoplatform/ergo/blob/95139ec990fc91f8f4c70a61468edf8834143329/src/main/scala/scorex/core/network/peer/InMemoryPeerDatabase.scala#L14 for details. Ignore blacklisting and penalizing peers methods since we don't need them now.


Discussion / hints:
I'm making a start on this one

Fix patch:
diff --git a/ergo-p2p/Cargo.toml b/ergo-p2p/Cargo.toml
index b6553ba51..fbd22cd5a 100644
--- a/ergo-p2p/Cargo.toml
+++ b/ergo-p2p/Cargo.toml
@@ -19,6 +19,8 @@ sigma-ser = { version = "^0.3.0", path = "../sigma-ser" }
 sigma-util = { version = "^0.3.0", path = "../sigma-util" }
 thiserror = "1"
 derive_more = "0.99"
+bounded-vec = { version = "^0.5.0" }
+proptest-derive = {version = "0.3.0", optional = true }
 
 [dependencies.proptest]
 # wasm support, via https://altsysrq.github.io/proptest-book/proptest/wasm.html
@@ -30,5 +32,9 @@ default-features = false
 features = ["std"]
 optional = true
 
+[features]
+arbitrary = ["proptest", "proptest-derive"]
 
 [dev-dependencies]
+sigma-test-util = { version = "^0.3.0", path = "../sigma-test-util" }
+ergo-p2p = { path = ".", features = ["arbitrary"] }
diff --git a/ergo-p2p/src/lib.rs b/ergo-p2p/src/lib.rs
index d9ca1239c..9b5157336 100644
--- a/ergo-p2p/src/lib.rs
+++ b/ergo-p2p/src/lib.rs
@@ -16,3 +16,17 @@
 #![deny(clippy::todo)]
 #![deny(clippy::unimplemented)]
 #![deny(clippy::panic)]
+
+mod peer_addr;
+mod peer_database;
+mod peer_feature;
+mod peer_info;
+mod peer_spec;
+mod protocol_version;
+
+pub use peer_addr::PeerAddr;
+pub use peer_database::{in_memory::InMemoryPeerDatabase, PeerDatabase, PeerDatabaseError};
+pub use peer_feature::{LocalAddressPeerFeature, PeerFeature, PeerFeatureId};
+pub use peer_info::PeerInfo;
+pub use peer_spec::PeerSpec;
+pub use protocol_version::ProtocolVersion;
diff --git a/ergo-p2p/src/peer_addr.rs b/ergo-p2p/src/peer_addr.rs
new file mode 100644
index 000000000..9c4f730c9
--- /dev/null
+++ b/ergo-p2p/src/peer_addr.rs
@@ -0,0 +1,91 @@
+//! Peer address types
+use std::{
+    convert::TryInto,
+    net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4},
+};
+
+use derive_more::{Display, From, Into};
+use sigma_ser::{ScorexSerializable, ScorexSerializationError};
+
+/// Peer address
+#[derive(PartialEq, Eq, Debug, Copy, Clone, From, Into, Hash, Display)]
+pub struct PeerAddr(SocketAddr);
+
+impl PeerAddr {
+    /// Size in bytes of the ip address associated with this peer address
+    pub fn ip_size(&self) -> usize {
+        match self.0.ip() {
+            IpAddr::V4(ip) => ip.octets().len(),
+            IpAddr::V6(ip) => ip.octets().len(),
+        }
+    }
+}
+
+impl ScorexSerializable for PeerAddr {
+    fn scorex_serialize<W: sigma_ser::vlq_encode::WriteSigmaVlqExt>(
+        &self,
+        w: &mut W,
+    ) -> sigma_ser::ScorexSerializeResult {
+        let ip = match self.0.ip() {
+            IpAddr::V4(ip) => ip,
+            _ => return Err(ScorexSerializationError::NotSupported("ipv6 not supported")),
+        };
+
+        w.write_all(&ip.octets())?;
+        w.put_u32(self.0.port() as u32)?;
+
+        Ok(())
+    }
+
+    fn scorex_parse<R: sigma_ser::vlq_encode::ReadSigmaVlqExt>(
+        r: &mut R,
+    ) -> Result<Self, sigma_ser::ScorexParsingError> {
+        let mut fa = [0u8; 4];
+        r.read_exact(&mut fa)?;
+
+        let ip = Ipv4Addr::from(fa);
+        let port: u16 = r.get_u32()?.try_into()?;
+
+        Ok(SocketAddr::V4(SocketAddrV4::new(ip, port)).into())
+    }
+}
+
+/// Arbitrary
+#[cfg(feature = "arbitrary")]
+pub mod arbitrary {
+    use super::*;
+    use proptest::prelude::*;
+    use proptest::prelude::{Arbitrary, BoxedStrategy};
+
+    impl Arbitrary for PeerAddr {
+        type Parameters = ();
+        type Strategy = BoxedStrategy<Self>;
+
+        fn arbitrary_with(_: Self::Parameters) -> Self::Strategy {
+            (any::<[u8; 4]>(), any::<u16>())
+                .prop_map(|(octets, port)| {
+                    SocketAddr::new(Ipv4Addr::from(octets).into(), port).into()
+                })
+                .boxed()
+        }
+    }
+}
+
+#[allow(clippy::panic)]
+#[allow(clippy::unwrap_used)]
+#[cfg(test)]
+#[cfg(feature = "arbitrary")]
+mod tests {
+    use super::*;
+    use proptest::prelude::*;
+    use sigma_ser::scorex_serialize_roundtrip;
+
+    proptest! {
+        #![proptest_config(ProptestConfig::with_cases(64))]
+
+        #[test]
+        fn ser_roundtrip(v in any::<PeerAddr>()) {
+            assert_eq![scorex_serialize_roundtrip(&v), v]
+        }
+    }
+}
diff --git a/ergo-p2p/src/peer_database.rs b/ergo-p2p/src/peer_database.rs
new file mode 100644
index 000000000..146677425
--- /dev/null
+++ b/ergo-p2p/src/peer_database.rs
@@ -0,0 +1,42 @@
+//! Peer database types
+
+pub mod in_memory;
+
+use std::collections::HashMap;
+
+use crate::{peer_addr::PeerAddr, peer_info::PeerInfo};
+
+type PeerMap = HashMap<PeerAddr, PeerInfo>;
+
+/// PeerDatabase errors
+#[derive(PartialEq, Eq, Debug)]
+pub enum PeerDatabaseError {
+    /// Peer was not found in the database
+    NotFound,
+    /// The peer had no address associated with it
+    /// The node spec had no declared address and the node isn't using the LocalAddress peer feature
+    NoPeerAddr,
+}
+
+/// Peer database trait
+/// Common operations for managing peers
+pub trait PeerDatabase {
+    /// Get `PeerInfo` based on the address
+    fn get_peer_by_addr(&self, addr: PeerAddr) -> Option<PeerInfo>;
+
+    /// Add a peer to the database
+    /// If a peer with the address already exists then it is updated and the old value is returned
+    /// If the database did not contain the peer info then None is returned
+    /// If the peer has no associated address PeerDatabaseError::NoPeerAddr is returned
+    fn add_or_update_peer(
+        &mut self,
+        peer_info: PeerInfo,
+    ) -> Result<Option<PeerInfo>, PeerDatabaseError>;
+
+    /// Remove peer from the database
+    /// Returns the peer if it existed in the database
+    fn remove_peer(&mut self, addr: PeerAddr) -> Result<(), PeerDatabaseError>;
+
+    /// Return a mapping of peer address -> peer info
+    fn known_peers(&self) -> PeerMap;
+}
diff --git a/ergo-p2p/src/peer_database/in_memory.rs b/ergo-p2p/src/peer_database/in_memory.rs
new file mode 100644
index 000000000..472024781
--- /dev/null
+++ b/ergo-p2p/src/peer_database/in_memory.rs
@@ -0,0 +1,122 @@
+use crate::{peer_addr::PeerAddr, peer_info::PeerInfo};
+
+use super::{PeerDatabase, PeerDatabaseError, PeerMap};
+
+/// In-memory peer database implementation
+#[derive(Default, Debug)]
+pub struct InMemoryPeerDatabase {
+    peers: PeerMap,
+}
+
+impl PeerDatabase for InMemoryPeerDatabase {
+    fn get_peer_by_addr(&self, addr: PeerAddr) -> Option<PeerInfo> {
+        Some(self.peers.get(&addr)?.clone())
+    }
+
+    fn add_or_update_peer(
+        &mut self,
+        peer_info: PeerInfo,
+    ) -> Result<Option<PeerInfo>, PeerDatabaseError> {
+        if let Some(addr) = peer_info.spec().addr() {
+            Ok(self.peers.insert(addr, peer_info))
+        } else {
+            Err(PeerDatabaseError::NoPeerAddr)
+        }
+    }
+
+    fn remove_peer(&mut self, addr: PeerAddr) -> Result<(), PeerDatabaseError> {
+        self.peers
+            .remove(&addr)
+            .ok_or(PeerDatabaseError::NotFound)?;
+
+        Ok(())
+    }
+
+    fn known_peers(&self) -> PeerMap {
+        self.peers.clone()
+    }
+}
+
+/// Arbitrary
+#[allow(unused_must_use)]
+#[cfg(feature = "arbitrary")]
+pub mod arbitrary {
+    use super::*;
+    use proptest::collection::vec;
+    use proptest::prelude::*;
+    use proptest::prelude::{Arbitrary, BoxedStrategy};
+
+    impl Arbitrary for InMemoryPeerDatabase {
+        type Parameters = ();
+        type Strategy = BoxedStrategy<Self>;
+
+        fn arbitrary_with(_: Self::Parameters) -> Self::Strategy {
+            (vec(any::<PeerInfo>(), 1..5))
+                .prop_map(|infos| {
+                    let mut db = Self::default();
+                    for i in infos {
+                        db.add_or_update_peer(i);
+                    }
+                    db
+                })
+                .boxed()
+        }
+    }
+
+    impl InMemoryPeerDatabase {
+        /// Seed database with a peer
+        pub fn with_peer(mut self, info: PeerInfo) -> InMemoryPeerDatabase {
+            self.add_or_update_peer(info);
+            self
+        }
+    }
+}
+
+#[allow(clippy::unwrap_used)]
+#[cfg(test)]
+#[cfg(feature = "arbitrary")]
+mod tests {
+    use super::*;
+
+    use sigma_test_util::force_any_val;
+
+    #[test]
+    fn get_peer_by_addr() {
+        let info = force_any_val::<PeerInfo>().with_ensured_addr();
+        let db = force_any_val::<InMemoryPeerDatabase>().with_peer(info.clone());
+        let result = db.get_peer_by_addr(info.spec().addr().unwrap()).unwrap();
+
+        assert_eq!(info, result);
+    }
+
+    // Test adding peer in the case its not already in the db
+    #[test]
+    fn add_or_update_peer_doesnt_exist() {
+        let mut db = force_any_val::<InMemoryPeerDatabase>();
+        let info = force_any_val::<PeerInfo>().with_ensured_addr();
+        db.add_or_update_peer(info.clone()).unwrap();
+
+        assert!(db.get_peer_by_addr(info.spec().addr().unwrap()).is_some())
+    }
+
+    // Test adding peer in the case it already exists
+    #[test]
+    fn add_or_update_peer_exists() {
+        let info = force_any_val::<PeerInfo>().with_ensured_addr();
+        let mut db = force_any_val::<InMemoryPeerDatabase>().with_peer(info.clone());
+        let result = db.add_or_update_peer(info.clone()).unwrap().unwrap();
+
+        // add or update returned the original peer_info
+        assert_eq!(result, info)
+    }
+
+    #[test]
+    fn remove_peer_db_contains_peer() {
+        let info = force_any_val::<PeerInfo>().with_ensured_addr();
+        let mut db = force_any_val::<InMemoryPeerDatabase>().with_peer(info.clone());
+        let result = db.remove_peer(info.spec().addr().unwrap());
+
+        assert!(result.is_ok());
+        assert!(db.get_peer_by_addr(info.spec().addr().unwrap()).is_none())
+    }
+}
diff --git a/ergo-p2p/src/peer_feature.rs b/ergo-p2p/src/peer_feature.rs
new file mode 100644
index 000000000..e89738ad7
--- /dev/null
+++ b/ergo-p2p/src/peer_feature.rs
@@ -0,0 +1,146 @@
+//! PeerFeature types
+use std::convert::TryInto;
+
+use derive_more::{From, Into};
+
+use sigma_ser::vlq_encode::WriteSigmaVlqExt;
+use sigma_ser::{ScorexParsingError, ScorexSerializable, ScorexSerializeResult};
+
+use crate::peer_addr::PeerAddr;
+
+/// Peer feature identifier
+#[derive(PartialEq, Eq, Debug, Copy, Clone, From, Into)]
+pub struct PeerFeatureId(u8);
+
+impl ScorexSerializable for PeerFeatureId {
+    fn scorex_serialize<W: sigma_ser::vlq_encode::WriteSigmaVlqExt>(
+        &self,
+        w: &mut W,
+    ) -> ScorexSerializeResult {
+        w.put_u8(self.0)?;
+
+        Ok(())
+    }
+
+    fn scorex_parse<R: sigma_ser::vlq_encode::ReadSigmaVlqExt>(
+        r: &mut R,
+    ) -> Result<Self, sigma_ser::ScorexParsingError> {
+        Ok(Self(r.get_u8()?))
+    }
+}
+
+/// Peer features
+#[derive(PartialEq, Eq, Debug, Hash, Clone)]
+pub enum PeerFeature {
+    /// Local address peer feature
+    LocalAddress(LocalAddressPeerFeature),
+}
+
+impl PeerFeature {
+    /// Id of the peer feature
+    pub fn id(&self) -> PeerFeatureId {
+        match self {
+            PeerFeature::LocalAddress(_) => PeerFeatureId(2),
+        }
+    }
+
+    /// Return the feature as a LocalAddressPeerFeature if its of that type
+    /// otherwise returns None
+    pub fn as_local_addr(&self) -> Option<&LocalAddressPeerFeature> {
+        match self {
+            PeerFeature::LocalAddress(pf) => Some(pf),
+        }
+    }
+}
+
+impl ScorexSerializable for PeerFeature {
+    fn scorex_serialize<W: WriteSigmaVlqExt>(&self, w: &mut W) -> ScorexSerializeResult {
+        self.id().scorex_serialize(w)?;
+
+        let bytes = match self {
+            PeerFeature::LocalAddress(pf) => pf.scorex_serialize_bytes(),
+        }?;
+
+        w.put_u16(bytes.len().try_into()?)?;
+        w.write_all(&bytes)?;
+
+        Ok(())
+    }
+
+    fn scorex_parse<R: sigma_ser::vlq_encode::ReadSigmaVlqExt>(
+        r: &mut R,
+    ) -> Result<Self, sigma_ser::ScorexParsingError> {
+        let feature_id = PeerFeatureId::scorex_parse(r)?;
+        let feature_size = r.get_u16()?;
+        let mut feature_buf = vec![0u8; feature_size as usize];
+        r.read_exact(&mut feature_buf)?;
+
+        let feature = match feature_id {
+            PeerFeatureId(2) => PeerFeature::LocalAddress(
+                LocalAddressPeerFeature::scorex_parse_bytes(&feature_buf)?,
+            ),
+            _ => return Err(ScorexParsingError::Misc("unknown feature id".into())),
+        };
+
+        Ok(feature)
+    }
+}
+
+/// Arbitrary
+#[cfg(feature = "arbitrary")]
+pub mod arbitrary {
+    use super::*;
+    use proptest::prelude::*;
+    use proptest::prelude::{Arbitrary, BoxedStrategy};
+
+    impl Arbitrary for PeerFeature {
+        type Parameters = ();
+        type Strategy = BoxedStrategy<Self>;
+
+        fn arbitrary_with(_: Self::Parameters) -> Self::Strategy {
+            prop_oneof![any::<LocalAddressPeerFeature>().prop_map(PeerFeature::LocalAddress)]
+                .boxed()
+        }
+    }
+}
+
+/// LocalAddressPeerFeature
+#[cfg_attr(feature = "arbitrary", derive(proptest_derive::Arbitrary))]
+#[derive(PartialEq, Eq, Debug, Hash, Clone, From, Into)]
+pub struct LocalAddressPeerFeature(PeerAddr);
+
+impl ScorexSerializable for LocalAddressPeerFeature {
+    fn scorex_serialize<W: sigma_ser::vlq_encode::WriteSigmaVlqExt>(
+        &self,
+        w: &mut W,
+    ) -> ScorexSerializeResult {
+        self.0.scorex_serialize(w)?;
+
+        Ok(())
+    }
+
+    fn scorex_parse<R: sigma_ser::vlq_encode::ReadSigmaVlqExt>(
+        r: &mut R,
+    ) -> Result<Self, sigma_ser::ScorexParsingError> {
+        Ok(PeerAddr::scorex_parse(r)?.into())
+    }
+}
+
+#[allow(clippy::panic)]
+#[allow(clippy::unwrap_used)]
+#[cfg(test)]
+#[cfg(feature = "arbitrary")]
+mod tests {
+    use super::*;
+    use proptest::prelude::*;
+    use sigma_ser::scorex_serialize_roundtrip;
+
+    proptest! {
+        #![proptest_config(ProptestConfig::with_cases(64))]
+
+        #[test]
+        fn local_address_feature_ser_roundtrip(v in any::<LocalAddressPeerFeature>()) {
+            assert_eq![scorex_serialize_roundtrip(&v), v]
+        }
+    }
+}
diff --git a/ergo-p2p/src/peer_info.rs b/ergo-p2p/src/peer_info.rs
new file mode 100644
index 000000000..301435f33
--- /dev/null
+++ b/ergo-p2p/src/peer_info.rs
@@ -0,0 +1,94 @@
+//! Peer info types
+
+use crate::{peer_addr::PeerAddr, peer_spec::PeerSpec, protocol_version::ProtocolVersion};
+
+/// Direction of the connection to a peer
+#[derive(PartialEq, Eq, Debug, Clone, Hash)]
+pub enum ConnectionDirection {
+    /// A peer is connecting to us
+    Incoming,
+    /// We are connecting to a peer
+    Outgoing,
+}
+
+/// Information about peer to be stored in PeerDatabase
+#[derive(PartialEq, Eq, Debug, Clone, Hash)]
+pub struct PeerInfo {
+    /// general information about the peer
+    peer_spec: PeerSpec,
+    /// timestamp when last handshake was done
+    last_handshake: u64,
+    /// type of connection (Incoming/Outgoing) established to this peer if any
+    conn_type: Option<ConnectionDirection>,
+}
+
+impl PeerInfo {
+    /// Create new PeerInfo instance
+    pub fn new(
+        peer_spec: PeerSpec,
+        last_handshake: u64,
+        conn_type: Option<ConnectionDirection>,
+    ) -> Self {
+        Self {
+            peer_spec,
+            last_handshake,
+            conn_type,
+        }
+    }
+
+    /// Return the PeerSpec associated with this PeerInfo
+    pub fn spec(&self) -> PeerSpec {
+        self.peer_spec.clone()
+    }
+
+    /// Create peer info from address only, when we don't know other fields
+    /// (e.g. we got this information from config or from API)
+    pub fn from_addr(addr: PeerAddr) -> PeerInfo {
+        let peer_spec = PeerSpec::new(
+            "unknown",
+            ProtocolVersion::INITIAL,
+            &format!("unknown-{}", addr.to_string()),
+            Some(addr),
+            None,
+        );
+
+        PeerInfo::new(peer_spec, 0, None)
+    }
+}
+
+/// Arbitrary
+#[cfg(feature = "arbitrary")]
+pub mod arbitrary {
+    use super::*;
+    use proptest::prelude::{Arbitrary, BoxedStrategy};
+    use proptest::{option, prelude::*};
+
+    impl Arbitrary for PeerInfo {
+        type Parameters = ();
+        type Strategy = BoxedStrategy<Self>;
+
+        fn arbitrary_with(_: Self::Parameters) -> Self::Strategy {
+            (
+                any::<PeerSpec>(),
+                any::<u64>(),
+                option::of(prop_oneof![
+                    Just(ConnectionDirection::Incoming),
+                    Just(ConnectionDirection::Outgoing)
+                ]),
+            )
+                .prop_map(|(spec, timestamp, direction)| PeerInfo::new(spec, timestamp, direction))
+                .boxed()
+        }
+    }
+
+    impl PeerInfo {
+        /// Ensure the PeerSpec has a valid addr
+        /// This can happen if declared_addr is none and there is no LocalAddressPeerFeature in features
+        pub fn with_ensured_addr(mut self) -> Self {
+            if self.peer_spec.addr().is_none() {
+                self.peer_spec = self.peer_spec.with_ensured_addr();
+            }
+            self
+        }
+    }
+}
diff --git a/ergo-p2p/src/peer_spec.rs b/ergo-p2p/src/peer_spec.rs
new file mode 100644
index 000000000..9202c9df9
--- /dev/null
+++ b/ergo-p2p/src/peer_spec.rs
@@ -0,0 +1,200 @@
+//! PeerSpec types
+use std::io;
+
+use bounded_vec::BoundedVec;
+use sigma_ser::vlq_encode::VlqEncodingError;
+use sigma_ser::{ScorexParsingError, ScorexSerializable, ScorexSerializeResult};
+
+use crate::peer_addr::PeerAddr;
+use crate::{peer_feature::PeerFeature, protocol_version::ProtocolVersion};
+
+type PeerFeatures = BoundedVec<PeerFeature, 1, { u8::MAX as usize }>;
+
+/// PeerSpec
+#[derive(PartialEq, Eq, Debug, Clone, Hash)]
+pub struct PeerSpec {
+    agent_name: String,
+    protocol_version: ProtocolVersion,
+    node_name: String,
+    declared_addr: Option<PeerAddr>,
+    features: Option<PeerFeatures>,
+}
+
+impl PeerSpec {
+    /// Create new PeerSpec instance
+    pub fn new(
+        agent_name: &str,
+        protocol_version: ProtocolVersion,
+        node_name: &str,
+        declared_addr: Option<PeerAddr>,
+        features: Option<PeerFeatures>,
+    ) -> Self {
+        Self {
+            agent_name: agent_name.into(),
+            protocol_version,
+            node_name: node_name.into(),
+            declared_addr,
+            features,
+        }
+    }
+
+    /// Local address of the peer if the peer is using the LocalAddress feature
+    pub fn local_addr(&self) -> Option<PeerAddr> {
+        Some(PeerAddr::from(
+            self.features
+                .as_ref()?
+                .iter()
+                .find_map(PeerFeature::as_local_addr)?
+                .to_owned(),
+        ))
+    }
+
+    /// Returns true if the peer is reachable
+    pub fn reachable_peer(&self) -> bool {
+        self.addr().is_some()
+    }
+
+    /// The address of the peer
+    /// Returns either the declared address or local address if either are valid
+    pub fn addr(&self) -> Option<PeerAddr> {
+        self.declared_addr.or_else(|| self.local_addr())
+    }
+}
+
+impl ScorexSerializable for PeerSpec {
+    fn scorex_serialize<W: sigma_ser::vlq_encode::WriteSigmaVlqExt>(
+        &self,
+        w: &mut W,
+    ) -> ScorexSerializeResult {
+        w.put_short_string(&self.agent_name)?;
+        self.protocol_version.scorex_serialize(w)?;
+        w.put_short_string(&self.node_name)?;
+
+        w.put_option(self.declared_addr, &|w: &mut W,
+                                           addr: PeerAddr|
+         -> io::Result<()> {
+            w.put_u8(addr.ip_size() as u8)?;
+            addr.scorex_serialize(w)?;
+
+            Ok(())
+        })?;
+
+        if let Some(feats) = &self.features {
+            w.put_u8(feats.len() as u8)?;
+            feats.iter().try_for_each(|f| f.scorex_serialize(w))?;
+        } else {
+            w.put_u8(0)?;
+        }
+
+        Ok(())
+    }
+
+    fn scorex_parse<R: sigma_ser::vlq_encode::ReadSigmaVlqExt>(
+        r: &mut R,
+    ) -> Result<Self, ScorexParsingError> {
+        let agent_name = r.get_short_string()?;
+
+        if agent_name.is_empty() {
+            return Err(ScorexParsingError::Io("agent name cannot be empty".into()));
+        }
+
+        let version = ProtocolVersion::scorex_parse(r)?;
+        let node_name = r.get_short_string()?;
+        let declared_addr: Option<PeerAddr> = r.get_option(&|r: &mut R| {
+            // read the size bytes
+            // not used at the moment becuase PeerAddr is currently ipv4/4 bytes
+            r.get_u8()?;
+            let addr =
+                PeerAddr::scorex_parse(r).map_err(|_| VlqEncodingError::VlqDecodingFailed)?;
+
+            Ok(addr)
+        });
+
+        let feat_len = r.get_u8()?;
+        let features = match feat_len {
+            0 => None,
+            n => {
+                let mut f: Vec<PeerFeature> = Vec::with_capacity(n as usize);
+                for _ in 0..n {
+                    f.push(PeerFeature::scorex_parse(r)?);
+                }
+                Some(BoundedVec::from_vec(f)?)
+            }
+        };
+
+        Ok(PeerSpec::new(
+            &agent_name,
+            version,
+            &node_name,
+            declared_addr,
+            features,
+        ))
+    }
+}
+
+/// Arbitrary
+#[cfg(feature = "arbitrary")]
+#[allow(clippy::unwrap_used, clippy::panic)]
+pub mod arbitrary {
+    use super::*;
+    use proptest::prelude::{Arbitrary, BoxedStrategy};
+    use proptest::test_runner::TestRunner;
+    use proptest::{collection::vec, option, prelude::*};
+
+    impl Arbitrary for PeerSpec {
+        type Parameters = ();
+        type Strategy = BoxedStrategy<Self>;
+
+        fn arbitrary_with(_: Self::Parameters) -> Self::Strategy {
+            (
+                any::<ProtocolVersion>(),
+                option::of(any::<PeerAddr>()),
+                option::of(vec(any::<PeerFeature>(), 1..4)),
+            )
+                .prop_map(|(version, declared_addr, features)| {
+                    let feats = features.map(|f| BoundedVec::from_vec(f).unwrap());
+
+                    PeerSpec::new(
+                        "/Ergo-Scala-client:2.0.0(iPad; U; CPU OS 3_2_1)/AndroidBuild:0.8/",
+                        version,
+                        "Testing node",
+                        declared_addr,
+                        feats,
+                    )
+                })
+                .boxed()
+        }
+    }
+
+    impl PeerSpec {
+        /// Ensure the PeerSpec has a valid addr
+        /// This can happen if declared_addr is none and there is no LocalAddressPeerFeature in features
+        pub fn with_ensured_addr(mut self) -> Self {
+            if self.addr().is_none() {
+                let mut runner = TestRunner::default();
+                self.declared_addr =
+                    Some(any::<PeerAddr>().new_tree(&mut runner).unwrap().current());
+            }
+            self
+        }
+    }
+}
+
+#[allow(clippy::panic)]
+#[allow(clippy::unwrap_used)]
+#[cfg(test)]
+#[cfg(feature = "arbitrary")]
+mod tests {
+    use super::*;
+    use proptest::prelude::*;
+    use sigma_ser::scorex_serialize_roundtrip;
+
+    proptest! {
+        #![proptest_config(ProptestConfig::with_cases(64))]
+
+        #[test]
+        fn ser_roundtrip(v in any::<PeerSpec>()) {
+            assert_eq![scorex_serialize_roundtrip(&v), v]
+        }
+    }
+}
diff --git a/ergo-p2p/src/protocol_version.rs b/ergo-p2p/src/protocol_version.rs
new file mode 100644
index 000000000..690cbb7a6
--- /dev/null
+++ b/ergo-p2p/src/protocol_version.rs
@@ -0,0 +1,82 @@
+//! Ergo P2P network version
+use sigma_ser::{ScorexSerializable, ScorexSerializeResult};
+
+/// P2P network protocol version
+/// Follows semantic versioning convention
+#[derive(PartialEq, Eq, Debug, Clone, Hash)]
+pub struct ProtocolVersion {
+    major: u8,
+    minor: u8,
+    patch: u8,
+}
+
+impl ProtocolVersion {
+    /// Create new ProtocolVersion instance
+    pub const fn new(major: u8, minor: u8, patch: u8) -> ProtocolVersion {
+        ProtocolVersion {
+            major,
+            minor,
+            patch,
+        }
+    }
+
+    /// Initial protocol version
+    pub const INITIAL: Self = ProtocolVersion::new(0, 0, 1);
+}
+
+impl ScorexSerializable for ProtocolVersion {
+    fn scorex_serialize<W: sigma_ser::vlq_encode::WriteSigmaVlqExt>(
+        &self,
+        w: &mut W,
+    ) -> ScorexSerializeResult {
+        w.put_u8(self.major)?;
+        w.put_u8(self.minor)?;
+        w.put_u8(self.patch)?;
+
+        Ok(())
+    }
+
+    fn scorex_parse<R: sigma_ser::vlq_encode::ReadSigmaVlqExt>(
+        r: &mut R,
+    ) -> Result<Self, sigma_ser::ScorexParsingError> {
+        Ok(ProtocolVersion::new(r.get_u8()?, r.get_u8()?, r.get_u8()?))
+    }
+}
+
+/// Arbitrary
+#[cfg(feature = "arbitrary")]
+pub mod arbitrary {
+    use super::*;
+    use proptest::prelude::*;
+    use proptest::prelude::{Arbitrary, BoxedStrategy};
+
+    impl Arbitrary for ProtocolVersion {
+        type Parameters = ();
+        type Strategy = BoxedStrategy<Self>;
+
+        fn arbitrary_with(_: Self::Parameters) -> Self::Strategy {
+            (any::<u8>(), any::<u8>(), any::<u8>())
+                .prop_map(|(major, minor, patch)| ProtocolVersion::new(major, minor, patch))
+                .boxed()
+        }
+    }
+}
+
+#[allow(clippy::panic)]
+#[allow(clippy::unwrap_used)]
+#[cfg(feature = "arbitrary")]
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use proptest::prelude::*;
+    use sigma_ser::scorex_serialize_roundtrip;
+
+    proptest! {
+        #![proptest_config(ProptestConfig::with_cases(64))]
+
+        #[test]
+        fn ser_roundtrip(v in any::<ProtocolVersion>()) {
+            assert_eq![scorex_serialize_roundtrip(&v), v]
+        }
+    }
+}
diff --git a/sigma-ser/src/lib.rs b/sigma-ser/src/lib.rs
index 520f85d9a..20792b1d3 100644
--- a/sigma-ser/src/lib.rs
+++ b/sigma-ser/src/lib.rs
@@ -21,6 +21,7 @@
 mod scorex_serialize;
 pub use scorex_serialize::{
     scorex_serialize_roundtrip, ScorexParsingError, ScorexSerializable, ScorexSerializationError,
+    ScorexSerializeResult,
 };
 /// VLQ encoder
 pub mod vlq_encode;
diff --git a/sigma-ser/src/scorex_serialize.rs b/sigma-ser/src/scorex_serialize.rs
index 187141650..167a2b8a9 100644
--- a/sigma-ser/src/scorex_serialize.rs
+++ b/sigma-ser/src/scorex_serialize.rs
@@ -18,6 +18,9 @@ pub enum ScorexSerializationError {
     /// Serialization not supported
     #[error("serialization not supported: {0}")]
     NotSupported(&'static str),
+    /// Integer type conversion failed
+    #[error("Bounds check error: {0}")]
+    TryFrom(#[from] std::num::TryFromIntError),
 }
 
 impl From<io::Error> for ScorexSerializationError {
@@ -26,6 +29,12 @@ impl From<io::Error> for ScorexSerializationError {
     }
 }
 
+impl From<ScorexSerializationError> for io::Error {
+    fn from(e: ScorexSerializationError) -> Self {
+        io::Error::new(io::ErrorKind::InvalidInput, e.to_string())
+    }
+}
+
 /// Ways parsing might fail
 #[derive(Error, Eq, PartialEq, Debug, Clone)]
 pub enum ScorexParsingError {
@@ -65,6 +74,9 @@ pub enum ScorexParsingError {
     /// Invalid item quantity for BoundedVec
     #[error("Invalid item quantity for BoundedVec: {0}")]
     BoundedVecOutOfBounds(#[from] BoundedVecOutOfBounds),
+    /// Failed to convert integer type
+    #[error("Bounds check error: {0}")]
+    TryFrom(#[from] std::num::TryFromIntError),
 }
 
 impl From<io::Error> for ScorexParsingError {
@@ -90,7 +102,7 @@ pub trait ScorexSerializable: Sized {
     fn scorex_parse<R: ReadSigmaVlqExt>(r: &mut R) -> Result<Self, ScorexParsingError>;
 
     /// Serialize a ScorexSerializable value into bytes
-    fn scorex_serialize_bytes(&self) -> Result<Vec<u8>, ScorexParsingError> {
+    fn scorex_serialize_bytes(&self) -> Result<Vec<u8>, ScorexSerializationError> {
         let mut w = vec![];
         self.scorex_serialize(&mut w)?;
         Ok(w)
diff --git a/sigma-ser/src/vlq_encode.rs b/sigma-ser/src/vlq_encode.rs
index c18d7dba0..131f4cb41 100644
--- a/sigma-ser/src/vlq_encode.rs
+++ b/sigma-ser/src/vlq_encode.rs
@@ -129,6 +129,22 @@ pub trait WriteSigmaVlqExt: io::Write {
         self.write_all(s.as_bytes())?;
         Ok(())
     }
+
+    /// Encode an optional value
+    fn put_option<T>(
+        &mut self,
+        opt: Option<T>,
+        put_value: &dyn Fn(&mut Self, T) -> io::Result<()>,
+    ) -> io::Result<()> {
+        match opt {
+            Some(s) => {
+                self.put_u8(1)?;
+                put_value(self, s)?;
+            }
+            None => self.put_u8(0)?,
+        }
+        Ok(())
+    }
 }
 
 /// Mark all types implementing `Write` as implementing the extension.
@@ -218,6 +234,19 @@ pub trait ReadSigmaVlqExt: io::Read {
         let string = String::from_utf8(bytes).map_err(|_| VlqEncodingError::VlqDecodingFailed)?;
         Ok(string)
     }
+
+    /// Read and decode an optional value using supplied function
+    fn get_option<T>(
+        &mut self,
+        get_value: &dyn Fn(&mut Self) -> Result<T, VlqEncodingError>,
+    ) -> Option<T> {
+        let is_opt = self.get_u8().ok()?;
+        match is_opt {
+            1 => Some(get_value(self).ok()?),
+            // Should only ever be 0 or 1
+            _ => None,
+        }
+    }
 }
 
 /// Mark all types implementing `Read` as implementing the extension.


Test patch: