diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 548d1f71..d2f134df 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -22,6 +22,11 @@ jobs: continue-on-error: true env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + - name: Publish client + run: cargo publish --manifest-path client/Cargo.toml + continue-on-error: true + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - name: Publish chain run: cargo publish --manifest-path chain/Cargo.toml continue-on-error: true diff --git a/Cargo.lock b/Cargo.lock index f79546f6..b93733c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,6 +67,22 @@ dependencies = [ "uuid", ] +[[package]] +name = "alto-client" +version = "0.0.2" +dependencies = [ + "alto-types", + "bytes", + "commonware-cryptography", + "commonware-utils", + "futures", + "rand", + "reqwest", + "thiserror 2.0.12", + "tokio", + "tokio-tungstenite", +] + [[package]] name = "alto-types" version = "0.0.2" @@ -566,6 +582,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" @@ -2890,6 +2912,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha-1" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.9.9" @@ -3271,6 +3304,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f714dd15bead90401d77e04243611caec13726c2408afd5b31901dfcdcb3b181" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.13" @@ -3389,6 +3436,26 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e27992fd6a8c29ee7eef28fc78349aa244134e10ad447ce3b9f0ac0ed0fa4ce0" +dependencies = [ + "base64 0.13.1", + "byteorder", + "bytes", + "http 0.2.12", + "httparse", + "log", + "native-tls", + "rand", + "sha-1", + "thiserror 1.0.69", + "url", + "utf-8", +] + [[package]] name = "typenum" version = "1.18.0" @@ -3440,6 +3507,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf16_iter" version = "1.0.5" diff --git a/Cargo.toml b/Cargo.toml index e86dd475..95121ec6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,13 @@ [workspace] members = [ "chain", + "client", "types", ] resolver = "2" [workspace.dependencies] +alto-client = { version = "0.0.2", path = "client" } alto-types = { version = "0.0.2", path = "types" } commonware-consensus = { version = "0.0.40" } commonware-cryptography = { version = "0.0.40" } diff --git a/README.md b/README.md index ab00bfb2..bb945cf3 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ ## Components * [chain](./chain/README.md): A minimal blockchain built with the [Commonware Library](https://github.com/commonwarexyz/monorepo). +* [client](./client/README.md): Client for interacting with `alto`. * [types](./types/README.md): Common types used throughout `alto`. ## Licensing diff --git a/client/Cargo.toml b/client/Cargo.toml new file mode 100644 index 00000000..40bbb016 --- /dev/null +++ b/client/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "alto-client" +version = "0.0.2" +publish = true +edition = "2021" +license = "MIT OR Apache-2.0" +description = "Client for interacting with alto." +readme = "README.md" +homepage = "https://alto.commonware.xyz" +repository = "https://github.com/commonwarexyz/alto/tree/main/client" +documentation = "https://docs.rs/alto-client" + +[dependencies] +alto-types = { workspace = true } +commonware-cryptography = { workspace = true } +commonware-utils = { workspace = true } +bytes = { workspace = true } +rand = { workspace = true } +thiserror = { workspace = true } +futures = { workspace = true } +reqwest = "0.12.12" +tokio-tungstenite = { version = "0.17", features = ["native-tls"] } +tokio = { version = "1.40.0", features = ["full"] } diff --git a/client/README.md b/client/README.md new file mode 100644 index 00000000..12bb4f46 --- /dev/null +++ b/client/README.md @@ -0,0 +1,10 @@ +# alto-client + +[![Crates.io](https://img.shields.io/crates/v/alto-client.svg)](https://crates.io/crates/alto-client) +[![Docs.rs](https://docs.rs/alto-client/badge.svg)](https://docs.rs/alto-client) + +Client for interacting with `alto`. + +## Status + +`alto-client` is **ALPHA** software and is not yet recommended for production use. Developers should expect breaking changes and occasional instability. \ No newline at end of file diff --git a/client/src/consensus.rs b/client/src/consensus.rs new file mode 100644 index 00000000..810a25fa --- /dev/null +++ b/client/src/consensus.rs @@ -0,0 +1,346 @@ +use crate::{Client, Error, IndexQuery, Query}; +use alto_types::{ + Block, Finalization, Finalized, Kind, Notarization, Notarized, Nullification, Seed, +}; +use futures::{channel::mpsc::unbounded, Stream, StreamExt}; +use tokio_tungstenite::{connect_async, tungstenite::Message as TMessage}; + +fn seed_upload_path(base: String) -> String { + format!("{}/seed", base) +} + +fn seed_get_path(base: String, query: &IndexQuery) -> String { + format!("{}/seed/{}", base, query.serialize()) +} + +fn nullification_upload_path(base: String) -> String { + format!("{}/nullification", base) +} + +fn nullification_get_path(base: String, query: &IndexQuery) -> String { + format!("{}/nullification/{}", base, query.serialize()) +} + +fn notarization_upload_path(base: String) -> String { + format!("{}/notarization", base) +} + +fn notarization_get_path(base: String, query: &IndexQuery) -> String { + format!("{}/notarization/{}", base, query.serialize()) +} + +fn finalization_upload_path(base: String) -> String { + format!("{}/finalization", base) +} + +fn finalization_get_path(base: String, query: &IndexQuery) -> String { + format!("{}/finalization/{}", base, query.serialize()) +} + +/// There is no block upload path. Blocks are uploaded as a byproduct of notarization +/// and finalization uploads. +fn block_get_path(base: String, query: &Query) -> String { + format!("{}/block/{}", base, query.serialize()) +} + +fn register_path(base: String) -> String { + format!("{}/consensus/ws", base) +} + +pub enum Payload { + Finalized(Box), + Block(Block), +} + +pub enum Message { + Seed(Seed), + Nullification(Nullification), + Notarization(Notarized), + Finalization(Finalized), +} + +impl Client { + pub async fn seed_upload(&self, seed: Seed) -> Result<(), Error> { + let request = seed.serialize(); + let client = reqwest::Client::new(); + let result = client + .post(seed_upload_path(self.uri.clone())) + .body(request) + .send() + .await + .map_err(Error::Reqwest)?; + if !result.status().is_success() { + return Err(Error::Failed(result.status())); + } + Ok(()) + } + + pub async fn seed_get(&self, query: IndexQuery) -> Result { + // Get the seed + let client = reqwest::Client::new(); + let result = client + .get(seed_get_path(self.uri.clone(), &query)) + .send() + .await + .map_err(Error::Reqwest)?; + if !result.status().is_success() { + return Err(Error::Failed(result.status())); + } + let bytes = result.bytes().await.map_err(Error::Reqwest)?; + let result = Seed::deserialize(Some(&self.public), &bytes).ok_or(Error::InvalidData)?; + + // Verify the seed matches the query + match query { + IndexQuery::Latest => {} + IndexQuery::Index(index) => { + if result.view != index { + return Err(Error::InvalidData); + } + } + } + Ok(result) + } + + pub async fn nullification_upload(&self, nullification: Nullification) -> Result<(), Error> { + let request = nullification.serialize(); + let client = reqwest::Client::new(); + let result = client + .post(nullification_upload_path(self.uri.clone())) + .body(request) + .send() + .await + .map_err(Error::Reqwest)?; + if !result.status().is_success() { + return Err(Error::Failed(result.status())); + } + Ok(()) + } + + pub async fn nullification_get(&self, query: IndexQuery) -> Result { + // Get the nullification + let client = reqwest::Client::new(); + let result = client + .get(nullification_get_path(self.uri.clone(), &query)) + .send() + .await + .map_err(Error::Reqwest)?; + if !result.status().is_success() { + return Err(Error::Failed(result.status())); + } + let bytes = result.bytes().await.map_err(Error::Reqwest)?; + let result = + Nullification::deserialize(Some(&self.public), &bytes).ok_or(Error::InvalidData)?; + + // Verify the nullification matches the query + match query { + IndexQuery::Latest => {} + IndexQuery::Index(index) => { + if result.view != index { + return Err(Error::InvalidData); + } + } + } + Ok(result) + } + + pub async fn notarization_upload( + &self, + proof: Notarization, + block: Block, + ) -> Result<(), Error> { + let request = Notarized::new(proof, block).serialize(); + let client = reqwest::Client::new(); + let result = client + .post(notarization_upload_path(self.uri.clone())) + .body(request) + .send() + .await + .map_err(Error::Reqwest)?; + if !result.status().is_success() { + return Err(Error::Failed(result.status())); + } + Ok(()) + } + + pub async fn notarization_get(&self, query: IndexQuery) -> Result { + // Get the notarization + let client = reqwest::Client::new(); + let result = client + .get(notarization_get_path(self.uri.clone(), &query)) + .send() + .await + .map_err(Error::Reqwest)?; + if !result.status().is_success() { + return Err(Error::Failed(result.status())); + } + let bytes = result.bytes().await.map_err(Error::Reqwest)?; + let result = + Notarized::deserialize(Some(&self.public), &bytes).ok_or(Error::InvalidData)?; + + // Verify the notarization matches the query + match query { + IndexQuery::Latest => {} + IndexQuery::Index(index) => { + if result.proof.view != index { + return Err(Error::InvalidData); + } + } + } + Ok(result) + } + + pub async fn finalization_upload( + &self, + proof: Finalization, + block: Block, + ) -> Result<(), Error> { + let request = Finalized::new(proof, block).serialize(); + let client = reqwest::Client::new(); + let result = client + .post(finalization_upload_path(self.uri.clone())) + .body(request) + .send() + .await + .map_err(Error::Reqwest)?; + if !result.status().is_success() { + return Err(Error::Failed(result.status())); + } + Ok(()) + } + + pub async fn finalization_get(&self, query: IndexQuery) -> Result { + // Get the finalization + let client = reqwest::Client::new(); + let result = client + .get(finalization_get_path(self.uri.clone(), &query)) + .send() + .await + .map_err(Error::Reqwest)?; + if !result.status().is_success() { + return Err(Error::Failed(result.status())); + } + let bytes = result.bytes().await.map_err(Error::Reqwest)?; + let result = + Finalized::deserialize(Some(&self.public), &bytes).ok_or(Error::InvalidData)?; + + // Verify the finalization matches the query + match query { + IndexQuery::Latest => {} + IndexQuery::Index(index) => { + if result.proof.view != index { + return Err(Error::InvalidData); + } + } + } + Ok(result) + } + + pub async fn block_get(&self, query: Query) -> Result { + // Get the block + let client = reqwest::Client::new(); + let result = client + .get(block_get_path(self.uri.clone(), &query)) + .send() + .await + .map_err(Error::Reqwest)?; + if !result.status().is_success() { + return Err(Error::Failed(result.status())); + } + let bytes = result.bytes().await.map_err(Error::Reqwest)?; + + // Verify the block matches the query + let result = match query { + Query::Latest => { + let result = + Finalized::deserialize(Some(&self.public), &bytes).ok_or(Error::InvalidData)?; + Payload::Finalized(Box::new(result)) + } + Query::Index(index) => { + let result = + Finalized::deserialize(Some(&self.public), &bytes).ok_or(Error::InvalidData)?; + if result.block.height != index { + return Err(Error::InvalidData); + } + Payload::Finalized(Box::new(result)) + } + Query::Digest(digest) => { + let result = Block::deserialize(&bytes).ok_or(Error::InvalidData)?; + if result.digest() != digest { + return Err(Error::InvalidData); + } + Payload::Block(result) + } + }; + Ok(result) + } + + pub async fn register(&self) -> Result>, Error> { + // Connect to the websocket endpoint + let (stream, _) = connect_async(register_path(self.ws_uri.clone())) + .await + .map_err(Error::from)?; + let (_, read) = stream.split(); + + // Create an unbounded channel for streaming consensus messages + let public = self.public.clone(); + let (sender, receiver) = unbounded(); + tokio::spawn(async move { + read.for_each(|message| async { + match message { + Ok(TMessage::Binary(data)) => { + // Get kind + let kind = data[0]; + let Some(kind) = Kind::from_u8(kind) else { + let _ = sender.unbounded_send(Err(Error::InvalidData)); + return; + }; + let data = &data[1..]; + + // Deserialize the message + match kind { + Kind::Seed => { + if let Some(seed) = Seed::deserialize(Some(&public), data) { + let _ = sender.unbounded_send(Ok(Message::Seed(seed))); + } else { + let _ = sender.unbounded_send(Err(Error::InvalidData)); + } + } + Kind::Notarization => { + if let Some(payload) = Notarized::deserialize(Some(&public), data) { + let _ = + sender.unbounded_send(Ok(Message::Notarization(payload))); + } else { + let _ = sender.unbounded_send(Err(Error::InvalidData)); + } + } + Kind::Nullification => { + if let Some(nullification) = + Nullification::deserialize(Some(&public), data) + { + let _ = sender + .unbounded_send(Ok(Message::Nullification(nullification))); + } else { + let _ = sender.unbounded_send(Err(Error::InvalidData)); + } + } + Kind::Finalization => { + if let Some(payload) = Finalized::deserialize(Some(&public), data) { + let _ = + sender.unbounded_send(Ok(Message::Finalization(payload))); + } else { + let _ = sender.unbounded_send(Err(Error::InvalidData)); + } + } + } + } + Ok(_) => {} // Ignore non-binary messages. + Err(e) => { + let _ = sender.unbounded_send(Err(Error::from(e))); + } + } + }) + .await; + }); + Ok(receiver) + } +} diff --git a/client/src/lib.rs b/client/src/lib.rs new file mode 100644 index 00000000..fe7e1c0a --- /dev/null +++ b/client/src/lib.rs @@ -0,0 +1,70 @@ +//! Client for interacting with `alto`. + +use commonware_cryptography::{bls12381, sha256::Digest}; +use commonware_utils::hex; +use thiserror::Error; + +pub mod consensus; +pub mod utils; + +const LATEST: &str = "latest"; + +pub enum Query { + Latest, + Index(u64), + Digest(Digest), +} + +impl Query { + pub fn serialize(&self) -> String { + match self { + Query::Latest => LATEST.to_string(), + Query::Index(index) => hex(&index.to_be_bytes()), + Query::Digest(digest) => hex(digest), + } + } +} + +pub enum IndexQuery { + Latest, + Index(u64), +} + +impl IndexQuery { + pub fn serialize(&self) -> String { + match self { + IndexQuery::Latest => LATEST.to_string(), + IndexQuery::Index(index) => hex(&index.to_be_bytes()), + } + } +} + +#[derive(Error, Debug)] +pub enum Error { + #[error("reqwest error: {0}")] + Reqwest(#[from] reqwest::Error), + #[error("tungstenite error: {0}")] + Tungstenite(#[from] tokio_tungstenite::tungstenite::Error), + #[error("failed: {0}")] + Failed(reqwest::StatusCode), + #[error("invalid data")] + InvalidData, +} + +pub struct Client { + uri: String, + ws_uri: String, + public: bls12381::PublicKey, +} + +impl Client { + pub fn new(uri: &str, public: bls12381::PublicKey) -> Self { + let uri = uri.to_string(); + let ws_uri = uri.replace("http", "ws"); + Self { + uri, + ws_uri, + public, + } + } +} diff --git a/client/src/utils.rs b/client/src/utils.rs new file mode 100644 index 00000000..8d2d6a19 --- /dev/null +++ b/client/src/utils.rs @@ -0,0 +1,20 @@ +use crate::{Client, Error}; + +fn healthy_path(base: String) -> String { + format!("{}/health", base) +} + +impl Client { + pub async fn health(&self) -> Result<(), Error> { + let client = reqwest::Client::new(); + let result = client + .get(healthy_path(self.uri.clone())) + .send() + .await + .map_err(Error::from)?; + if !result.status().is_success() { + return Err(Error::Failed(result.status())); + } + Ok(()) + } +}