diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18eeab43..1174edc8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,7 +78,7 @@ jobs: cache-on-failure: true - name: cargo hack run: | - cargo hack build --workspace --target wasm32-unknown-unknown --exclude op-alloy-network --exclude op-alloy-rpc-types --exclude op-alloy-rpc-jsonrpsee + cargo hack build --workspace --target wasm32-unknown-unknown --exclude op-alloy-network --exclude op-alloy-rpc-types --exclude op-alloy-rpc-jsonrpsee --exclude op-alloy-provider wasm-wasi: runs-on: ubuntu-latest @@ -94,7 +94,7 @@ jobs: cache-on-failure: true - name: cargo hack run: | - cargo hack build --workspace --target wasm32-wasi --exclude op-alloy-network --exclude op-alloy-rpc-types --exclude op-alloy-rpc-jsonrpsee + cargo hack build --workspace --target wasm32-wasi --exclude op-alloy-network --exclude op-alloy-rpc-types --exclude op-alloy-rpc-jsonrpsee --exclude op-alloy-provider no-std: runs-on: ubuntu-latest diff --git a/Cargo.toml b/Cargo.toml index d03e5a4e..771b0763 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,8 @@ alloy-rpc-types-eth = { version = "0.3.6", default-features = false } alloy-eips = { version = "0.3.6", default-features = false } alloy-serde = { version = "0.3.6", default-features = false } alloy-signer = { version = "0.3.6", default-features = false } +alloy-provider = { version = "0.3.6", default-features = false } +alloy-transport = { version = "0.3.6", default-features = false } # Serde serde_repr = "0.1" diff --git a/crates/provider/Cargo.toml b/crates/provider/Cargo.toml new file mode 100644 index 00000000..1ec69eaa --- /dev/null +++ b/crates/provider/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "op-alloy-provider" +description = "Interface with an OP Stack blockchain" + +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +authors.workspace = true +repository.workspace = true +exclude.workspace = true + +[dependencies] +# Workspace +op-alloy-rpc-types-engine = { workspace = true, features = ["serde"] } + +# Alloy +alloy-primitives = { workspace = true, features = ["rlp", "serde"] } +alloy-rpc-types-engine = { workspace = true, features = ["serde"] } +alloy-provider.workspace = true +alloy-network.workspace = true +alloy-transport.workspace = true + +# misc +async-trait = "0.1.82" diff --git a/crates/provider/README.md b/crates/provider/README.md new file mode 100644 index 00000000..5d91db07 --- /dev/null +++ b/crates/provider/README.md @@ -0,0 +1,3 @@ +# alloy-provider + +Interface with an OP Stack blockchain. diff --git a/crates/provider/src/ext/engine.rs b/crates/provider/src/ext/engine.rs new file mode 100644 index 00000000..95439da0 --- /dev/null +++ b/crates/provider/src/ext/engine.rs @@ -0,0 +1,308 @@ +use alloy_network::Network; +use alloy_primitives::{BlockHash, B256}; +use alloy_provider::Provider; +use alloy_rpc_types_engine::{ + ClientVersionV1, ExecutionPayloadBodiesV1, ExecutionPayloadEnvelopeV2, ExecutionPayloadInputV2, + ExecutionPayloadV3, ExecutionPayloadV4, ForkchoiceState, ForkchoiceUpdated, PayloadId, + PayloadStatus, +}; +use alloy_transport::{Transport, TransportResult}; +use op_alloy_rpc_types_engine::{ + OptimismExecutionPayloadEnvelopeV3, OptimismExecutionPayloadEnvelopeV4, + OptimismPayloadAttributes, ProtocolVersion, SuperchainSignal, +}; + +/// Extension trait that gives access to Optimism engine API RPC methods. +/// +/// Note: +/// > The provider should use a JWT authentication layer. +/// +/// This follows the Optimism specs that can be found at: +/// +#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] +pub trait OpEngineApi: Send + Sync { + /// Sends the given payload to the execution layer client, as specified for the Shanghai fork. + /// + /// See also + /// + /// No modifications needed for OP compatibility. + async fn new_payload_v2( + &self, + payload: ExecutionPayloadInputV2, + ) -> TransportResult; + + /// Sends the given payload to the execution layer client, as specified for the Cancun fork. + /// + /// See also + /// + /// OP modifications: + /// - expected versioned hashes MUST be an empty array: therefore the `versioned_hashes` + /// parameter is removed. + /// - parent beacon block root MUST be the parent beacon block root from the L1 origin block of + /// the L2 block. + async fn new_payload_v3( + &self, + payload: ExecutionPayloadV3, + parent_beacon_block_root: B256, + ) -> TransportResult; + + /// Sends the given payload to the execution layer client, as specified for the Prague fork. + /// + /// See also + /// + /// OP modifications: TODO + async fn new_payload_v4( + &self, + payload: ExecutionPayloadV4, + parent_beacon_block_root: B256, + ) -> TransportResult; + + /// Updates the execution layer client with the given fork choice, as specified for the Shanghai + /// fork. + /// + /// Caution: This should not accept the `parentBeaconBlockRoot` field in the payload attributes. + /// + /// See also + /// + /// OP modifications: + /// - The `payload_attributes` parameter is extended with the `OptimismPayloadAttributes` type + /// as described in + async fn fork_choice_updated_v2( + &self, + fork_choice_state: ForkchoiceState, + payload_attributes: Option, + ) -> TransportResult; + + /// Updates the execution layer client with the given fork choice, as specified for the Cancun + /// fork. + /// + /// See also + /// + /// OP modifications: + /// - Must be called with an Ecotone payload + /// - Attributes must contain the parent beacon block root field + /// - The `payload_attributes` parameter is extended with the `OptimismPayloadAttributes` type + /// as described in + async fn fork_choice_updated_v3( + &self, + fork_choice_state: ForkchoiceState, + payload_attributes: Option, + ) -> TransportResult; + + /// Retrieves an execution payload from a previously started build process, as specified for the + /// Shanghai fork. + /// + /// See also + /// + /// Note: + /// > Provider software MAY stop the corresponding build process after serving this call. + /// + /// No modifications needed for OP compatibility. + async fn get_payload_v2( + &self, + payload_id: PayloadId, + ) -> TransportResult; + + /// Retrieves an execution payload from a previously started build process, as specified for the + /// Cancun fork. + /// + /// See also + /// + /// Note: + /// > Provider software MAY stop the corresponding build process after serving this call. + /// + /// OP modifications: + /// - the response type is extended to [`OptimismExecutionPayloadEnvelopeV3`]. + async fn get_payload_v3( + &self, + payload_id: PayloadId, + ) -> TransportResult; + + /// Returns the most recent version of the payload that is available in the corresponding + /// payload build process at the time of receiving this call. + /// + /// See also + /// + /// Note: + /// > Provider software MAY stop the corresponding build process after serving this call. + /// + /// OP modifications: + /// - the response type is extended to [`OptimismExecutionPayloadEnvelopeV4`]. + async fn get_payload_v4( + &self, + payload_id: PayloadId, + ) -> TransportResult; + + /// Returns the execution payload bodies by the given hash. + /// + /// See also + async fn get_payload_bodies_by_hash_v1( + &self, + block_hashes: Vec, + ) -> TransportResult; + + /// Returns the execution payload bodies by the range starting at `start`, containing `count` + /// blocks. + /// + /// WARNING: This method is associated with the BeaconBlocksByRange message in the consensus + /// layer p2p specification, meaning the input should be treated as untrusted or potentially + /// adversarial. + /// + /// Implementers should take care when acting on the input to this method, specifically + /// ensuring that the range is limited properly, and that the range boundaries are computed + /// correctly and without panics. + /// + /// See also + async fn get_payload_bodies_by_range_v1( + &self, + start: u64, + count: u64, + ) -> TransportResult; + + /// Returns the execution client version information. + /// + /// Note: + /// > The `client_version` parameter identifies the consensus client. + /// + /// See also + async fn get_client_version_v1( + &self, + client_version: ClientVersionV1, + ) -> TransportResult>; + + /// Returns the list of Engine API methods supported by the execution layer client software. + /// + /// See also + async fn exchange_capabilities( + &self, + capabilities: Vec, + ) -> TransportResult>; + + /// Signals superchain information to the Engine + /// + /// V1 signals which protocol version is recommended and required. + async fn signal_superchain_v1( + &self, + signal: SuperchainSignal, + ) -> TransportResult; +} + +#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] +impl OpEngineApi for P +where + N: Network, + T: Transport + Clone, + P: Provider, +{ + async fn new_payload_v2( + &self, + payload: ExecutionPayloadInputV2, + ) -> TransportResult { + self.client().request("engine_newPayloadV2", (payload,)).await + } + + async fn new_payload_v3( + &self, + payload: ExecutionPayloadV3, + parent_beacon_block_root: B256, + ) -> TransportResult { + // Note: The `versioned_hashes` parameter is always an empty array for OP chains. + let versioned_hashes: Vec = vec![]; + + self.client() + .request("engine_newPayloadV3", (payload, versioned_hashes, parent_beacon_block_root)) + .await + } + + async fn new_payload_v4( + &self, + payload: ExecutionPayloadV4, + parent_beacon_block_root: B256, + ) -> TransportResult { + // Note: The `versioned_hashes` parameter is always an empty array for OP chains. + let versioned_hashes: Vec = vec![]; + + self.client() + .request("engine_newPayloadV4", (payload, versioned_hashes, parent_beacon_block_root)) + .await + } + + async fn fork_choice_updated_v2( + &self, + fork_choice_state: ForkchoiceState, + payload_attributes: Option, + ) -> TransportResult { + self.client() + .request("engine_forkchoiceUpdatedV2", (fork_choice_state, payload_attributes)) + .await + } + + async fn fork_choice_updated_v3( + &self, + fork_choice_state: ForkchoiceState, + payload_attributes: Option, + ) -> TransportResult { + self.client() + .request("engine_forkchoiceUpdatedV3", (fork_choice_state, payload_attributes)) + .await + } + + async fn get_payload_v2( + &self, + payload_id: PayloadId, + ) -> TransportResult { + self.client().request("engine_getPayloadV2", (payload_id,)).await + } + + async fn get_payload_v3( + &self, + payload_id: PayloadId, + ) -> TransportResult { + self.client().request("engine_getPayloadV3", (payload_id,)).await + } + + async fn get_payload_v4( + &self, + payload_id: PayloadId, + ) -> TransportResult { + self.client().request("engine_getPayloadV4", (payload_id,)).await + } + + async fn get_payload_bodies_by_hash_v1( + &self, + block_hashes: Vec, + ) -> TransportResult { + self.client().request("engine_getPayloadBodiesByHashV1", (block_hashes,)).await + } + + async fn get_payload_bodies_by_range_v1( + &self, + start: u64, + count: u64, + ) -> TransportResult { + self.client().request("engine_getPayloadBodiesByRangeV1", (start, count)).await + } + + async fn get_client_version_v1( + &self, + client_version: ClientVersionV1, + ) -> TransportResult> { + self.client().request("engine_getClientVersionV1", (client_version,)).await + } + + async fn exchange_capabilities( + &self, + capabilities: Vec, + ) -> TransportResult> { + self.client().request("engine_exchangeCapabilities", (capabilities,)).await + } + + async fn signal_superchain_v1( + &self, + signal: SuperchainSignal, + ) -> TransportResult { + self.client().request("engine_signalSuperchainV1", (signal,)).await + } +} diff --git a/crates/provider/src/ext/mod.rs b/crates/provider/src/ext/mod.rs new file mode 100644 index 00000000..d9888a49 --- /dev/null +++ b/crates/provider/src/ext/mod.rs @@ -0,0 +1,4 @@ +//! Extended APIs for the OP provider module. + +/// Engine API extension. +pub mod engine; diff --git a/crates/provider/src/lib.rs b/crates/provider/src/lib.rs new file mode 100644 index 00000000..367d6ece --- /dev/null +++ b/crates/provider/src/lib.rs @@ -0,0 +1,18 @@ +#![doc = include_str!("../README.md")] +#![doc( + html_logo_url = "https://raw.githubusercontent.com/alloy-rs/core/main/assets/alloy.jpg", + html_favicon_url = "https://raw.githubusercontent.com/alloy-rs/core/main/assets/favicon.ico" +)] +#![warn( + missing_copy_implementations, + missing_debug_implementations, + missing_docs, + unreachable_pub, + clippy::missing_const_for_fn, + rustdoc::all +)] +#![deny(unused_must_use, rust_2018_idioms)] +#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#![cfg_attr(not(test), warn(unused_crate_dependencies))] + +pub mod ext; diff --git a/crates/rpc-types-engine/src/lib.rs b/crates/rpc-types-engine/src/lib.rs index a7f124ca..46fca284 100644 --- a/crates/rpc-types-engine/src/lib.rs +++ b/crates/rpc-types-engine/src/lib.rs @@ -36,6 +36,9 @@ pub use payload_v3::OptimismExecutionPayloadEnvelopeV3; mod payload_v4; pub use payload_v4::OptimismExecutionPayloadEnvelopeV4; +mod superchain; +pub use superchain::{ProtocolVersion, SuperchainSignal}; + mod errors; pub use errors::{ToL2BlockRefError, ToSystemConfigError}; diff --git a/crates/rpc-types-engine/src/superchain.rs b/crates/rpc-types-engine/src/superchain.rs new file mode 100644 index 00000000..0af437a9 --- /dev/null +++ b/crates/rpc-types-engine/src/superchain.rs @@ -0,0 +1,401 @@ +use alloc::{ + format, + string::{String, ToString}, +}; +use core::array::TryFromSliceError; + +use alloy_primitives::{B256, B64}; +use derive_more::derive::{Display, From}; + +/// Superchain Signal information. +/// +/// The execution engine SHOULD warn the user when the recommended version is newer than the current +/// version supported by the execution engine. +/// +/// The execution engine SHOULD take safety precautions if it does not meet the required protocol +/// version. This may include halting the engine, with consent of the execution engine operator. +/// +/// See also: +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +pub struct SuperchainSignal { + /// The recommended Supercain Protocol Version. + pub recommended: ProtocolVersion, + /// The minimum Supercain Protocol Version required. + pub required: ProtocolVersion, +} + +/// Formatted Superchain Protocol Version. +/// +/// The Protocol Version documents the progression of the total set of canonical OP-Stack +/// specifications. Components of the OP-Stack implement the subset of their respective protocol +/// component domain, up to a given Protocol Version of the OP-Stack. +/// +/// The Protocol Version **is NOT a hardfork identifier**, but rather indicates software-support for +/// a well-defined set of features introduced in past and future hardforks, not the activation of +/// said hardforks. +/// +/// The Protocol Version is Semver-compatible. It is encoded as a single 32 bytes long +/// protocol version. The version must be encoded as 32 bytes of DATA in JSON RPC usage. +/// +/// See also: +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum ProtocolVersion { + /// Version-type 0. + V0(ProtocolVersionFormatV0), +} + +impl core::fmt::Display for ProtocolVersion { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + ProtocolVersion::V0(value) => write!(f, "{}", value), + } + } +} + +#[derive(Copy, Clone, Debug, Display, From)] +pub enum ProtocolVersionError { + #[display("Unsupported version: {}", _0)] + UnsupportedVersion(u8), + #[display("Invalid length: got {}, expected {}", got, expected)] + InvalidLength { got: usize, expected: usize }, + #[display("Failed to convert slice to array")] + #[from(TryFromSliceError)] + TryFromSlice, +} + +impl ProtocolVersion { + /// Version-type 0 byte encoding: + /// + /// ```text + /// ::= + /// ::= + /// ::= <31 bytes> + /// ``` + pub fn encode(&self) -> B256 { + let mut bytes = [0u8; 32]; + + match self { + ProtocolVersion::V0(value) => { + bytes[0] = 0x00; // this is not necessary, but addded for clarity + bytes[1..].copy_from_slice(&value.encode()); + B256::from_slice(&bytes) + } + } + } + + /// Version-type 0 byte decoding: + /// + /// ```text + /// ::= + /// ::= + /// ::= <31 bytes> + /// ``` + pub fn decode(value: B256) -> Result { + let version_type = value[0]; + let typed_payload = &value[1..]; + + match version_type { + 0 => Ok(Self::V0(ProtocolVersionFormatV0::decode(typed_payload)?)), + other => Err(ProtocolVersionError::UnsupportedVersion(other)), + } + } + + /// Returns the inner value of the ProtocolVersion enum + pub const fn inner(&self) -> ProtocolVersionFormatV0 { + match self { + ProtocolVersion::V0(value) => *value, + } + } + + /// Returns the inner value of the ProtocolVersion enum if it is V0, otherwise None + pub const fn as_v0(&self) -> Option { + match self { + ProtocolVersion::V0(value) => Some(*value), + } + } + + /// Differentiates forks and custom-builds of standard protocol + pub const fn build(&self) -> B64 { + match self { + ProtocolVersion::V0(value) => value.build, + } + } + + /// Incompatible API changes + pub const fn major(&self) -> u32 { + match self { + ProtocolVersion::V0(value) => value.major, + } + } + + /// Identifies additional functionality in backwards compatible manner + pub const fn minor(&self) -> u32 { + match self { + ProtocolVersion::V0(value) => value.minor, + } + } + + /// Identifies backward-compatible bug-fixes + pub const fn patch(&self) -> u32 { + match self { + ProtocolVersion::V0(value) => value.patch, + } + } + + /// Identifies unstable versions that may not satisfy the above + pub const fn pre_release(&self) -> u32 { + match self { + ProtocolVersion::V0(value) => value.pre_release, + } + } + + /// Returns a human-readable string representation of the ProtocolVersion + pub fn display(&self) -> String { + match self { + ProtocolVersion::V0(value) => format!("{}", value), + } + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for ProtocolVersion { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.encode().serialize(serializer) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for ProtocolVersion { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = alloy_primitives::B256::deserialize(deserializer)?; + ProtocolVersion::decode(value).map_err(serde::de::Error::custom) + } +} + +/// The Protocol Version V0 format. +/// Encoded as 31 bytes with the following structure: +/// +/// ```text +/// +/// ::= <7 zeroed bytes> +/// ::= <8 bytes> +/// ::= +/// ::= +/// ::= +/// ::= +/// ``` +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct ProtocolVersionFormatV0 { + /// Differentiates forks and custom-builds of standard protocol + pub build: B64, + /// Incompatible API changes + pub major: u32, + /// Identifies additional functionality in backwards compatible manner + pub minor: u32, + /// Identifies backward-compatible bug-fixes + pub patch: u32, + /// Identifies unstable versions that may not satisfy the above + pub pre_release: u32, +} + +impl core::fmt::Display for ProtocolVersionFormatV0 { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let build_tag = if self.build.0.iter().any(|&byte| byte != 0) { + if is_human_readable_build_tag(self.build) { + let full = format!("+{}", String::from_utf8_lossy(&self.build.0)); + full.trim_end_matches('\0').to_string() + } else { + format!("+{}", self.build) + } + } else { + String::new() + }; + + let pre_release_tag = + if self.pre_release != 0 { format!("-{}", self.pre_release) } else { String::new() }; + + write!(f, "v{}.{}.{}{}{}", self.major, self.minor, self.patch, pre_release_tag, build_tag) + } +} + +impl ProtocolVersionFormatV0 { + /// Version-type 0 byte encoding: + /// + /// ```text + /// + /// ::= <7 zeroed bytes> + /// ::= <8 bytes> + /// ::= + /// ::= + /// ::= + /// ::= + /// ``` + pub fn encode(&self) -> [u8; 31] { + let mut bytes = [0u8; 31]; + bytes[0..7].copy_from_slice(&[0u8; 7]); + bytes[7..15].copy_from_slice(&self.build.0); + bytes[15..19].copy_from_slice(&self.major.to_be_bytes()); + bytes[19..23].copy_from_slice(&self.minor.to_be_bytes()); + bytes[23..27].copy_from_slice(&self.patch.to_be_bytes()); + bytes[27..31].copy_from_slice(&self.pre_release.to_be_bytes()); + bytes + } + + /// Version-type 0 byte encoding: + /// + /// ```text + /// + /// ::= <7 zeroed bytes> + /// ::= <8 bytes> + /// ::= + /// ::= + /// ::= + /// ::= + /// ``` + fn decode(value: &[u8]) -> Result { + if value.len() != 31 { + return Err(ProtocolVersionError::InvalidLength { got: value.len(), expected: 31 }); + } + + Ok(Self { + build: B64::from_slice(&value[7..15]), + major: u32::from_be_bytes(value[15..19].try_into()?), + minor: u32::from_be_bytes(value[19..23].try_into()?), + patch: u32::from_be_bytes(value[23..27].try_into()?), + pre_release: u32::from_be_bytes(value[27..31].try_into()?), + }) + } +} + +/// Returns true if the build tag is human-readable, false otherwise. +fn is_human_readable_build_tag(build: B64) -> bool { + for (i, &c) in build.iter().enumerate() { + if c == 0 { + // Trailing zeros are allowed + if build[i..].iter().any(|&d| d != 0) { + return false; + } + return true; + } + + // following semver.org advertised regex, alphanumeric with '-' and '.', except leading '.'. + if !(c.is_ascii_alphanumeric() || c == b'-' || (c == b'.' && i > 0)) { + return false; + } + } + true +} + +#[cfg(test)] +mod tests { + use alloy_primitives::b256; + + use super::*; + + #[test] + fn test_protocol_version_encode_decode() { + let test_cases = vec![ + ( + ProtocolVersion::V0(ProtocolVersionFormatV0 { + build: B64::from_slice(&[0x61, 0x62, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]), + major: 42, + minor: 0, + patch: 2, + pre_release: 0, + }), + "v42.0.2+0x6162010000000000", + b256!("000000000000000061620100000000000000002a000000000000000200000000"), + ), + ( + ProtocolVersion::V0(ProtocolVersionFormatV0 { + build: B64::from_slice(&[0x61, 0x62, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]), + major: 42, + minor: 0, + patch: 2, + pre_release: 1, + }), + "v42.0.2-1+0x6162010000000000", + b256!("000000000000000061620100000000000000002a000000000000000200000001"), + ), + ( + ProtocolVersion::V0(ProtocolVersionFormatV0 { + build: B64::from_slice(&[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]), + major: 42, + minor: 0, + patch: 2, + pre_release: 0, + }), + "v42.0.2+0x0102030405060708", + b256!("000000000000000001020304050607080000002a000000000000000200000000"), + ), + ( + ProtocolVersion::V0(ProtocolVersionFormatV0 { + build: B64::from_slice(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), + major: 0, + minor: 100, + patch: 2, + pre_release: 0, + }), + "v0.100.2", + b256!("0000000000000000000000000000000000000000000000640000000200000000"), + ), + ( + ProtocolVersion::V0(ProtocolVersionFormatV0 { + build: B64::from_slice(&[b'O', b'P', b'-', b'm', b'o', b'd', 0x00, 0x00]), + major: 42, + minor: 0, + patch: 2, + pre_release: 1, + }), + "v42.0.2-1+OP-mod", + b256!("00000000000000004f502d6d6f6400000000002a000000000000000200000001"), + ), + ( + ProtocolVersion::V0(ProtocolVersionFormatV0 { + build: B64::from_slice(&[b'a', b'b', 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]), + major: 42, + minor: 0, + patch: 2, + pre_release: 0, + }), + "v42.0.2+0x6162010000000000", // do not render invalid alpha numeric + b256!("000000000000000061620100000000000000002a000000000000000200000000"), + ), + ( + ProtocolVersion::V0(ProtocolVersionFormatV0 { + build: B64::from_slice(b"beta.123"), + major: 1, + minor: 0, + patch: 0, + pre_release: 0, + }), + "v1.0.0+beta.123", + b256!("0000000000000000626574612e31323300000001000000000000000000000000"), + ), + ]; + + for (decoded_exp, formatted_exp, encoded_exp) in test_cases { + encode_decode_v0(encoded_exp, formatted_exp, decoded_exp); + } + } + + fn encode_decode_v0(encoded_exp: B256, formatted_exp: &str, decoded_exp: ProtocolVersion) { + let decoded = ProtocolVersion::decode(encoded_exp).unwrap(); + assert_eq!(decoded, decoded_exp); + + let encoded = decoded.encode(); + assert_eq!(encoded, encoded_exp); + + let formatted = decoded.display(); + assert_eq!(formatted, formatted_exp); + } +}