diff --git a/Cargo.lock b/Cargo.lock index be5adc4e87..6fade8c8ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3349,9 +3349,13 @@ dependencies = [ "hotshot-task", "hotshot-types", "hotshot-utils", + "hs-builder-api", "jf-primitives", + "serde", "sha2 0.10.8", "snafu", + "surf-disco", + "tagged-base64", "time 0.3.34", "tokio", "tracing", @@ -3365,6 +3369,7 @@ dependencies = [ "async-compatibility-layer 1.0.0 (git+https://github.com/EspressoSystems/async-compatibility-layer.git?tag=1.4.2)", "async-lock 2.8.0", "async-std", + "async-trait", "bincode", "bitvec", "commit", @@ -3379,12 +3384,15 @@ dependencies = [ "hotshot-task-impls", "hotshot-types", "hotshot-utils", + "hs-builder-api", "jf-primitives", + "portpicker", "rand 0.8.5", "serde", "sha2 0.10.8", "sha3", "snafu", + "tide-disco", "tokio", "tracing", ] @@ -3489,6 +3497,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "hs-builder-api" +version = "0.1.1" +source = "git+https://github.com/EspressoSystems/hs-builder-api?branch=ag/types-0.1.2#acff0e1e698952c136e4ee8b52a6fbc258480670" +dependencies = [ + "async-trait", + "clap", + "derive_more", + "futures", + "hotshot-types", + "serde", + "snafu", + "tagged-base64", + "tide-disco", + "toml 0.8.11", +] + [[package]] name = "http" version = "0.2.11" diff --git a/Cargo.toml b/Cargo.toml index 72a7da84be..062b8369f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,10 +65,13 @@ jf-primitives = { git = "https://github.com/EspressoSystems/jellyfish", tag = "0 jf-plonk = { git = "https://github.com/EspressoSystems/jellyfish", tag = "0.4.1" } jf-relation = { git = "https://github.com/EspressoSystems/jellyfish", tag = "0.4.1" } jf-utils = { git = "https://github.com/espressosystems/jellyfish", tag = "0.4.1" } +# TODO: point to main when HotShot catches up to the same hotshot-types version as hs-builder-api +hs-builder-api = { git = "https://github.com/EspressoSystems/hs-builder-api", branch = "ag/types-0.1.2" } lazy_static = "1.4.0" libp2p-identity = "0.2" libp2p-networking = { path = "./crates/libp2p-networking", version = "0.1.0", default-features = false } libp2p-swarm-derive = { version = "0.34.1" } +portpicker = "0.1.1" rand = "0.8.5" rand_chacha = { version = "0.3.1", default-features = false } serde = { version = "1.0.197", features = ["derive"] } diff --git a/crates/task-impls/Cargo.toml b/crates/task-impls/Cargo.toml index 0f6346949d..a01656e8ff 100644 --- a/crates/task-impls/Cargo.toml +++ b/crates/task-impls/Cargo.toml @@ -15,6 +15,7 @@ async-lock = { workspace = true } tracing = { workspace = true } hotshot-types = { workspace = true } hotshot-utils = { path = "../utils" } +hs-builder-api = { workspace = true } jf-primitives = { workspace = true } time = { workspace = true } commit = { workspace = true } @@ -24,6 +25,9 @@ sha2 = { workspace = true } hotshot-task = { path = "../task" } async-broadcast = { workspace = true } chrono = "0.4" +surf-disco = { workspace = true } +serde = { workspace = true } +tagged-base64 = { workspace = true } [target.'cfg(all(async_executor_impl = "tokio"))'.dependencies] tokio = { workspace = true } diff --git a/crates/task-impls/src/builder.rs b/crates/task-impls/src/builder.rs new file mode 100644 index 0000000000..3e5e555207 --- /dev/null +++ b/crates/task-impls/src/builder.rs @@ -0,0 +1,127 @@ +use async_compatibility_layer::art::async_sleep; +use std::time::{Duration, Instant}; + +use hotshot_types::{ + traits::{node_implementation::NodeType, signature_key::SignatureKey}, + utils::BuilderCommitment, + vid::VidCommitment, +}; +use hs_builder_api::builder::{BuildError, Error as BuilderApiError}; +use serde::{Deserialize, Serialize}; +use snafu::Snafu; +use surf_disco::{client::HealthStatus, Client, Url}; +use tagged_base64::TaggedBase64; + +#[derive(Debug, Snafu, Serialize, Deserialize)] +/// Represents errors thant builder client may return +pub enum BuilderClientError { + // NOTE: folds BuilderError::NotFound & builderError::Missing + // into one. Maybe we'll want to handle that separately in + // the future + /// Block not found + #[snafu(display("Requested block not found"))] + NotFound, + /// Generic error while accessing the API, + /// i.e. when API isn't available or compatible + #[snafu(display("Builder API error: {message}"))] + Api { + /// Underlying error + message: String, + }, +} + +impl From for BuilderClientError { + fn from(value: BuilderApiError) -> Self { + match value { + BuilderApiError::Request { source } | BuilderApiError::TxnUnpack { source } => { + Self::Api { + message: source.to_string(), + } + } + BuilderApiError::TxnSubmit { source } => Self::Api { + message: source.to_string(), + }, + BuilderApiError::Custom { message, .. } => Self::Api { message }, + BuilderApiError::BlockAvailable { source, .. } + | BuilderApiError::BlockClaim { source, .. } => match source { + BuildError::NotFound | BuildError::Missing => Self::NotFound, + BuildError::Error { message } => Self::Api { message }, + }, + } + } +} + +/// Client for builder API +pub struct BuilderClient { + /// Underlying surf_disco::Client + inner: Client, + /// Marker for [`NodeType`] used here + _marker: std::marker::PhantomData, +} + +impl BuilderClient +where + <::SignatureKey as SignatureKey>::PureAssembledSignatureType: + for<'a> TryFrom<&'a TaggedBase64> + Into, +{ + /// Construct a new client from base url + pub fn new(base_url: impl Into) -> Self { + Self { + inner: Client::new(base_url.into()), + _marker: std::marker::PhantomData, + } + } + + /// Wait for server to become available + /// Returns `false` if server doesn't respond + /// with OK healthcheck before `timeout` + pub async fn connect(&self, timeout: Duration) -> bool { + let timeout = Instant::now() + timeout; + let mut backoff = Duration::from_millis(50); + while Instant::now() < timeout { + if matches!( + self.inner.healthcheck::().await, + Ok(HealthStatus::Available) + ) { + return true; + } + async_sleep(backoff).await; + backoff *= 2; + } + false + } + + /// Query builder for available blocks + /// + /// # Errors + /// - [`BuilderClientError::NotFound`] if blocks aren't available for this parent + /// - [`BuilderClientError::Api`] if API isn't responding or responds incorrectly + pub async fn get_avaliable_blocks( + &self, + parent: VidCommitment, + ) -> Result, BuilderClientError> { + self.inner + .get(&format!("availableblocks/{parent}")) + .send() + .await + .map_err(Into::into) + } + + /// Claim block + /// + /// # Errors + /// - [`BuilderClientError::NotFound`] if block isn't available + /// - [`BuilderClientError::Api`] if API isn't responding or responds incorrectly + pub async fn claim_block( + &self, + block_hash: BuilderCommitment, + signature: &<::SignatureKey as SignatureKey>::PureAssembledSignatureType, + ) -> Result { + let encoded_signature: TaggedBase64 = signature.clone().into(); + self.inner + .get(&format!("claimblock/{block_hash}/{encoded_signature}")) + .send() + .await + .map_err(Into::into) + } +} diff --git a/crates/task-impls/src/lib.rs b/crates/task-impls/src/lib.rs index e2d5ee3258..4489bda1d3 100644 --- a/crates/task-impls/src/lib.rs +++ b/crates/task-impls/src/lib.rs @@ -31,6 +31,10 @@ pub mod vote; /// Task for handling upgrades pub mod upgrade; +/// Implementations for builder client +/// Should contain builder task in the future +pub mod builder; + /// Helper functions used by any task pub mod helpers; diff --git a/crates/testing/Cargo.toml b/crates/testing/Cargo.toml index fab9b16c59..0074c0b16a 100644 --- a/crates/testing/Cargo.toml +++ b/crates/testing/Cargo.toml @@ -13,6 +13,7 @@ slow-tests = [] [dependencies] async-broadcast = { workspace = true } async-compatibility-layer = { workspace = true } +async-trait = { workspace = true } sha3 = "^0.10" bincode = { workspace = true } commit = { workspace = true } @@ -24,6 +25,7 @@ hotshot-utils = { path = "../utils" } hotshot-macros = { path = "../macros" } hotshot-orchestrator = { version = "0.1.1", path = "../orchestrator", default-features = false } hotshot-task-impls = { path = "../task-impls", version = "0.1.0", default-features = false } +hs-builder-api = { workspace = true } jf-primitives = { workspace = true } rand = { workspace = true } snafu = { workspace = true } @@ -35,6 +37,8 @@ bitvec = { workspace = true } ethereum-types = { workspace = true } hotshot-task = { path = "../task" } hotshot-example-types = { path = "../example-types" } +tide-disco = { workspace = true } +portpicker = { workspace = true } [target.'cfg(all(async_executor_impl = "tokio"))'.dependencies] tokio = { workspace = true } diff --git a/crates/testing/src/block_builder.rs b/crates/testing/src/block_builder.rs new file mode 100644 index 0000000000..5eb229dac6 --- /dev/null +++ b/crates/testing/src/block_builder.rs @@ -0,0 +1,124 @@ +use async_compatibility_layer::art::async_spawn; +use async_trait::async_trait; +use futures::future::BoxFuture; +use hotshot::traits::BlockPayload; +use hotshot::types::SignatureKey; +use hotshot_example_types::{block_types::TestBlockPayload, node_types::TestTypes}; +use hotshot_types::traits::block_contents::vid_commitment; +use hotshot_types::utils::BuilderCommitment; +use hotshot_types::{traits::node_implementation::NodeType, vid::VidCommitment}; +use hs_builder_api::block_info::{ + AvailableBlockData, AvailableBlockHeaderInput, AvailableBlockInfo, +}; +use hs_builder_api::{ + builder::{BuildError, Options}, + data_source::BuilderDataSource, +}; +use tide_disco::{method::ReadState, App, Url}; + +/// The only block [`TestableBuilderSource`] provides +const EMPTY_BLOCK: TestBlockPayload = TestBlockPayload { + transactions: vec![], +}; + +/// A mock implementation of the builder data source. +/// "Builds" only empty blocks. +pub struct TestableBuilderSource { + priv_key: <::SignatureKey as SignatureKey>::PrivateKey, + pub_key: ::SignatureKey, +} + +#[async_trait] +impl ReadState for TestableBuilderSource { + type State = Self; + + async fn read( + &self, + op: impl Send + for<'a> FnOnce(&'a Self::State) -> BoxFuture<'a, T> + 'async_trait, + ) -> T { + op(self).await + } +} + +#[async_trait] +impl BuilderDataSource for TestableBuilderSource { + async fn get_available_blocks( + &self, + _for_parent: &VidCommitment, + ) -> Result>, BuildError> { + Ok(vec![AvailableBlockInfo { + sender: self.pub_key, + signature: ::SignatureKey::sign( + &self.priv_key, + EMPTY_BLOCK.builder_commitment(&()).as_ref(), + ) + .unwrap(), + block_hash: EMPTY_BLOCK.builder_commitment(&()), + block_size: 0, + offered_fee: 1, + _phantom: std::marker::PhantomData, + }]) + } + + async fn claim_block( + &self, + block_hash: &BuilderCommitment, + _signature: &<::SignatureKey as SignatureKey>::PureAssembledSignatureType, + ) -> Result, BuildError> { + if block_hash == &EMPTY_BLOCK.builder_commitment(&()) { + Ok(AvailableBlockData { + block_payload: EMPTY_BLOCK, + metadata: (), + signature: ::SignatureKey::sign( + &self.priv_key, + EMPTY_BLOCK.builder_commitment(&()).as_ref(), + ) + .unwrap(), + sender: self.pub_key, + _phantom: std::marker::PhantomData, + }) + } else { + Err(BuildError::Missing) + } + } + + async fn claim_block_header_input( + &self, + block_hash: &BuilderCommitment, + _signature: &<::SignatureKey as SignatureKey>::PureAssembledSignatureType, + ) -> Result, BuildError> { + if block_hash == &EMPTY_BLOCK.builder_commitment(&()) { + Ok(AvailableBlockHeaderInput { + vid_commitment: vid_commitment(&vec![], 1), + signature: ::SignatureKey::sign( + &self.priv_key, + EMPTY_BLOCK.builder_commitment(&()).as_ref(), + ) + .unwrap(), + sender: self.pub_key, + _phantom: std::marker::PhantomData, + }) + } else { + Err(BuildError::Missing) + } + } +} + +/// Construct a tide disco app that mocks the builder API. +/// +/// # Panics +/// If constructing and launching the builder fails for any reason +pub fn run_builder(url: Url) { + let builder_api = hs_builder_api::builder::define_api::( + &Options::default(), + ) + .expect("Failed to construct the builder API"); + let (pub_key, priv_key) = + ::SignatureKey::generated_from_seed_indexed([1; 32], 0); + let mut app: App = + App::with_state(TestableBuilderSource { priv_key, pub_key }); + app.register_module("/", builder_api) + .expect("Failed to register the builder API"); + + async_spawn(app.serve(url)); +} diff --git a/crates/testing/src/lib.rs b/crates/testing/src/lib.rs index d9ae432175..6810620e5d 100644 --- a/crates/testing/src/lib.rs +++ b/crates/testing/src/lib.rs @@ -33,6 +33,9 @@ pub mod spinning_task; /// task for checking if view sync got activated pub mod view_sync_task; +/// Test implementation of block builder +pub mod block_builder; + /// predicates to use in tests pub mod predicates; diff --git a/crates/testing/tests/block_builder.rs b/crates/testing/tests/block_builder.rs new file mode 100644 index 0000000000..86d25e3346 --- /dev/null +++ b/crates/testing/tests/block_builder.rs @@ -0,0 +1,59 @@ +use hotshot_example_types::{ + block_types::{TestBlockPayload, TestTransaction}, + node_types::TestTypes, +}; +use hotshot_task_impls::builder::{BuilderClient, BuilderClientError}; +use hotshot_testing::block_builder::run_builder; +use hotshot_types::traits::BlockPayload; +use hotshot_types::traits::{ + block_contents::vid_commitment, node_implementation::NodeType, signature_key::SignatureKey, +}; +use std::time::Duration; +use tide_disco::Url; + +#[cfg(test)] +#[cfg_attr( + async_executor_impl = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(async_executor_impl = "async-std", async_std::test)] +async fn test_block_builder() { + let port = portpicker::pick_unused_port().expect("Could not find an open port"); + let api_url = Url::parse(format!("http://localhost:{port}").as_str()).unwrap(); + + run_builder(api_url.clone()); + + let client: BuilderClient = BuilderClient::new(api_url); + assert!(client.connect(Duration::from_millis(100)).await); + + // Test getting blocks + let mut blocks = client + .get_avaliable_blocks(vid_commitment(&vec![], 1)) + .await + .expect("Failed to get avaliable blocks"); + + assert_eq!(blocks.len(), 1); + + // Test claiming available block + let signature = { + let (_key, private_key) = + ::SignatureKey::generated_from_seed_indexed([0_u8; 32], 0); + ::SignatureKey::sign(&private_key, &[0_u8; 32]) + .expect("Failed to create dummy signature") + }; + + let _: TestBlockPayload = client + .claim_block(blocks.pop().unwrap(), &signature) + .await + .expect("Failed to claim block"); + + // Test claiming non-existent block + let commitment_for_non_existent_block = TestBlockPayload { + transactions: vec![TestTransaction(vec![0; 1])], + } + .builder_commitment(&()); + let result = client + .claim_block(commitment_for_non_existent_block, &signature) + .await; + assert!(matches!(result, Err(BuilderClientError::NotFound))); +}