diff --git a/Cargo.lock b/Cargo.lock index ced0a12..e9d8f30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2627,6 +2627,7 @@ dependencies = [ "inquire", "log", "rand 0.8.5", + "regex", "reqwest", "serde", "serde_json", @@ -2634,8 +2635,10 @@ dependencies = [ "strum", "strum_macros", "thiserror", + "time", "tokio", "toml", + "url", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6666b10..d32ab97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ hex = { version = "0.4.3", features = [] } inquire = "0.6.2" log = "0.4.20" rand = "0.8.5" +regex = "1.10.2" reqwest = { version = "0.11.23", features = ["json", "blocking"] } serde = { version = "1.0.193", features = ["derive"] } serde_json = "1.0.109" @@ -30,5 +31,7 @@ sp-core = "27.0.0" strum = { version = "0.25.0", features = ["derive"] } strum_macros = { version = "0.25.3", features = [] } thiserror = "1.0.52" +time = "0.3.31" tokio = { version = "1.35.1", features = ["rt", "rt-multi-thread", "macros"] } toml = "0.8.8" +url = "2.5.0" diff --git a/src/cli/explorer.rs b/src/cli/explorer.rs index 1918314..c301a1f 100644 --- a/src/cli/explorer.rs +++ b/src/cli/explorer.rs @@ -53,6 +53,7 @@ pub async fn explorer(opts: &ExplorerOpts) { CONTAINER_NAME, Some(env), Some(host_config), + None, ) .await; log::info!("🧭 Explorer is running on http://localhost:4000"); diff --git a/src/cli/init.rs b/src/cli/init.rs index 0291a0f..060764d 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -67,7 +67,7 @@ async fn generate_config() -> Result { config_version, }; - match DAFactory::new_da(&da_layer).setup_and_generate_keypair(&config) { + match DAFactory::new_da(&da_layer).generate_da_config(&config).await { Ok(_) => (), Err(err) => { log::error!("Failed to generate keypair: {}", err); diff --git a/src/da/avail.rs b/src/da/avail.rs index b87a2b5..0191b50 100644 --- a/src/da/avail.rs +++ b/src/da/avail.rs @@ -1,13 +1,14 @@ -use async_trait::async_trait; use std::fs; -use crate::app::config::AppChainConfig; -use crate::cli::prompt::get_boolean_input; +use async_trait::async_trait; +use eyre::Result as EyreResult; use hex::encode; use serde::{Deserialize, Serialize}; use sp_core::{sr25519, Pair}; use thiserror::Error; +use crate::app::config::AppChainConfig; +use crate::cli::prompt::get_boolean_input; use crate::da::da_layers::{DaClient, DaError}; pub struct AvailClient; @@ -33,7 +34,7 @@ const AVAIL_DOCS: &str = "https://docs.availproject.org/about/faucet/"; #[async_trait] impl DaClient for AvailClient { - fn setup_and_generate_keypair(&self, config: &AppChainConfig) -> Result<(), DaError> { + async fn generate_da_config(&self, config: &AppChainConfig) -> EyreResult<()> { let file_path = self.get_da_config_path(config)?; let file_path_str = file_path.to_string_lossy().to_string(); let (pair, phrase, seed) = ::generate_with_phrase(None); @@ -54,7 +55,7 @@ impl DaClient for AvailClient { ); } - generate_config(file_path_str.as_str(), &seed_str, pair.public().to_string().as_str())?; + write_config(file_path_str.as_str(), &seed_str, pair.public().to_string().as_str())?; Ok(()) } @@ -83,7 +84,7 @@ impl DaClient for AvailClient { } } -fn generate_config(da_config_path: &str, seed: &str, address: &str) -> Result<(), DaError> { +fn write_config(da_config_path: &str, seed: &str, address: &str) -> Result<(), DaError> { let avail_config = AvailConfig { ws_provider: "wss://goldberg.avail.tools:443/ws".to_string(), mode: "sovereign".to_string(), diff --git a/src/da/celestia.rs b/src/da/celestia.rs new file mode 100644 index 0000000..6227ec5 --- /dev/null +++ b/src/da/celestia.rs @@ -0,0 +1,190 @@ +use std::collections::HashMap; +use std::fs; +use std::io::Error; +use std::path::PathBuf; + +use async_trait::async_trait; +use bollard::models::{HostConfig, Mount, PortBinding}; +use eyre::Report as EyreReport; +use eyre::Result as EyreResult; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::app::config::AppChainConfig; +use crate::cli::prompt::get_boolean_input; +use crate::da::da_layers::{DaClient, DaError}; +use crate::utils::docker::{container_exists, is_container_running, kill_container, run_docker_image}; +use crate::utils::paths::get_madara_home; +use std::time::Duration; + +pub struct CelestiaClient; + +#[derive(Debug, Serialize, Deserialize)] +pub struct CelestiaConfig { + pub ws_provides: String, + pub http_provider: String, + pub nid: String, + pub auth_token: String, + pub address: String, +} + +#[derive(Error, Debug)] +pub enum CelestiaError { + #[error("Faucet funds needed for DA to be submitted")] + FaucetFundsNeeded, + #[error("Celestia light node setup failed")] + SetupError, + #[error("Failed to read celestia home")] + FailedToReadCelestiaHome, + #[error("Failed to run in celestia container")] + FailedToRunInCelestiaContainer, +} + +const CELESTIA_DOCS: &str = "https://docs.celestia.org/developers/celestia-app-wallet#fund-a-wallet"; +const CELESTIA_CONTAINER_NAME: &str = "celestia-light-client"; + +#[async_trait] +impl DaClient for CelestiaClient { + async fn generate_da_config(&self, config: &AppChainConfig) -> EyreResult<()> { + let celestia_home = get_celestia_home()?; + let file_keys_txt = celestia_home.join("keys.txt"); + let file_auth_txt = celestia_home.join("auth.txt"); + + if !file_keys_txt.exists() || !file_auth_txt.exists() { + let run_cmd = vec![ + "sh", + "-c", + "celestia light init --p2p.network=mocha > /home/celestia/keys.txt &&\ + celestia light auth admin --p2p.network=mocha > /home/celestia/auth.txt", + ]; + exec_cmd_in_celestia_container(run_cmd).await?; + // Waits for docker container to execute the commands and generate the keys + loop { + let container_exists = is_container_running(CELESTIA_CONTAINER_NAME).await; + if !container_exists { + break; // Container has exited + } + + // Sleep for a brief period to avoid excessive polling + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + + let file_path = self.get_da_config_path(config)?; + let file_path_str = file_path.to_string_lossy().to_string(); + + let keys_txt_content = fs::read_to_string(file_keys_txt)?; + let auth_token = fs::read_to_string(file_auth_txt)?; + + let mut address: &str = ""; + for line in keys_txt_content.lines() { + if line.trim().starts_with("ADDRESS:") { + address = line.trim_start_matches("ADDRESS:").trim(); + log::info!("🔑 Secret phrase stored in app home: {}", celestia_home.to_string_lossy().to_string()); + log::info!("💧 Celestia address: {}", address); + log::info!( + "=> Please fund your Celestia address to be able to submit blobs to the mocha network. Docs: {}", + CELESTIA_DOCS + ) + } + } + + if address.is_empty() || auth_token.is_empty() { + return Err(EyreReport::from(DaError::CelestiaError(CelestiaError::SetupError))); + } + + write_config(file_path_str.as_str(), auth_token.trim(), address)?; + + Ok(()) + } + + fn confirm_minimum_balance(&self, config: &AppChainConfig) -> Result<(), DaError> { + let celestia_config_path = self.get_da_config_path(config)?; + let celestia_config: CelestiaConfig = serde_json::from_str( + fs::read_to_string(celestia_config_path).map_err(DaError::FailedToReadDaConfigFile)?.as_str(), + ) + .map_err(DaError::FailedToDeserializeDaConfig)?; + match get_boolean_input( + format!( + "Have you funded your Celestia address {} using the faucet? Docs: {}", + celestia_config.address, CELESTIA_DOCS + ) + .as_str(), + Some(true), + )? { + true => Ok(()), + false => Err(DaError::CelestiaError(CelestiaError::FaucetFundsNeeded)), + } + } + + async fn setup(&self, _config: &AppChainConfig) -> eyre::Result<()> { + let run_cmd = vec!["sh", "-c", "celestia light start --core.ip=rpc-mocha.pops.one --p2p.network=mocha"]; + exec_cmd_in_celestia_container(run_cmd).await + } +} + +pub async fn exec_cmd_in_celestia_container(run_cmd: Vec<&str>) -> EyreResult<()> { + let celestia_home = get_celestia_home()?; + let celestia_home_str = celestia_home.to_str().unwrap_or("~/.madara/celestia"); + + let env = vec!["NODE_TYPE=light", "P2P_NETWORK=mocha"]; + + let mut port_bindings = HashMap::new(); + port_bindings.insert( + "26658/tcp".to_string(), + Some(vec![PortBinding { host_ip: Some("0.0.0.0".to_string()), host_port: Some("26658".to_string()) }]), + ); + + let host_config = HostConfig { + mounts: Some(vec![Mount { + target: Some("/home/celestia".to_string()), + source: Some(celestia_home_str.to_string()), + typ: Some(bollard::models::MountTypeEnum::BIND), + ..Default::default() + }]), + port_bindings: Some(port_bindings), + ..Default::default() + }; + + if container_exists(CELESTIA_CONTAINER_NAME).await { + // TODO: handle error + let _ = kill_container(CELESTIA_CONTAINER_NAME).await; + } + + run_docker_image( + "ghcr.io/celestiaorg/celestia-node:v0.12.2", + CELESTIA_CONTAINER_NAME, + Some(env), + Some(host_config), + Some(run_cmd), + ) + .await; + log::info!("🧭 Command ran on Celestia light client\n"); + + Ok(()) +} + +fn write_config(da_config_path: &str, auth_token: &str, address: &str) -> Result<(), DaError> { + let celestia_config = CelestiaConfig { + ws_provides: "http://127.0.0.1:26658".to_string(), + http_provider: "http://127.0.0.1:26658".to_string(), + nid: "Madara".to_string(), + auth_token: auth_token.to_string(), + address: address.to_string(), + }; + + fs::write(da_config_path, serde_json::to_string(&celestia_config).map_err(DaError::FailedToSerializeDaConfig)?) + .map_err(DaError::FailedToWriteDaConfigToFile)?; + + Ok(()) +} + +pub fn get_celestia_home() -> Result { + let madara_home = get_madara_home()?; + let celestia_home = madara_home.join("celestia"); + + // Creates the `celestia` directory if not present + fs::create_dir_all(&celestia_home)?; + + Ok(celestia_home) +} diff --git a/src/da/da_layers.rs b/src/da/da_layers.rs index 4674df4..dde110f 100644 --- a/src/da/da_layers.rs +++ b/src/da/da_layers.rs @@ -1,23 +1,24 @@ -use async_trait::async_trait; use std::io; use std::path::PathBuf; +use async_trait::async_trait; +use eyre::Result as EyreResult; use serde::{Deserialize, Serialize}; use strum_macros::{Display, EnumIter}; use thiserror::Error; use crate::app::config::AppChainConfig; use crate::da::avail::{AvailClient, AvailError}; -use crate::da::ethereum::EthereumClient; -use crate::da::ethereum::EthereumError; +use crate::da::celestia::{CelestiaClient, CelestiaError}; +use crate::da::ethereum::{EthereumClient, EthereumError}; use crate::da::no_da::NoDAConfig; use crate::utils::constants::APP_DA_CONFIG_NAME; use crate::utils::paths::get_app_home; -use eyre::Result as EyreResult; #[derive(Debug, Serialize, Deserialize, EnumIter, Display, Clone)] pub enum DALayer { Avail, + Celestia, Ethereum, NoDA, } @@ -28,6 +29,8 @@ pub enum DaError { AvailError(#[from] AvailError), #[error("ethereum error: {0}")] EthereumError(#[from] EthereumError), + #[error("celestia error: {0}")] + CelestiaError(#[from] CelestiaError), #[error("failed to read app home: {0}")] FailedToReadAppHome(io::Error), #[error("inquire error")] @@ -44,7 +47,7 @@ pub enum DaError { #[async_trait] pub trait DaClient { - fn setup_and_generate_keypair(&self, config: &AppChainConfig) -> Result<(), DaError>; + async fn generate_da_config(&self, config: &AppChainConfig) -> EyreResult<()>; fn confirm_minimum_balance(&self, config: &AppChainConfig) -> Result<(), DaError>; @@ -61,6 +64,7 @@ impl DAFactory { pub fn new_da(da: &DALayer) -> Box { match da { DALayer::Avail => Box::new(AvailClient {}), + DALayer::Celestia => Box::new(CelestiaClient {}), DALayer::Ethereum => Box::new(EthereumClient {}), _ => Box::new(NoDAConfig {}), } diff --git a/src/da/ethereum.rs b/src/da/ethereum.rs index 9332ae0..593dd75 100644 --- a/src/da/ethereum.rs +++ b/src/da/ethereum.rs @@ -1,23 +1,21 @@ -use crate::app::config::AppChainConfig; -use crate::da::da_layers::{DaClient, DaError}; -use crate::utils::serde::bytes_from_hex_str; -use async_trait::async_trait; -use eyre::Result as EyreResult; +use std::fs; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; +use async_trait::async_trait; use ethers::contract::abigen; - use ethers::middleware::SignerMiddleware; use ethers::providers::{Http, Provider}; use ethers::signers::{LocalWallet, Signer, WalletError}; - +use eyre::Result as EyreResult; use serde::{Deserialize, Serialize}; -use std::fs; +use thiserror::Error; +use crate::app::config::AppChainConfig; use crate::cli::prompt::get_boolean_input; -use std::str::FromStr; -use std::sync::Arc; -use std::time::Duration; -use thiserror::Error; +use crate::da::da_layers::{DaClient, DaError}; +use crate::utils::serde::bytes_from_hex_str; pub struct EthereumClient; @@ -45,7 +43,7 @@ const ANVIL_DOCS: &str = "https://github.com/foundry-rs/foundry/tree/master/crat #[async_trait] impl DaClient for EthereumClient { - fn setup_and_generate_keypair(&self, config: &AppChainConfig) -> Result<(), DaError> { + async fn generate_da_config(&self, config: &AppChainConfig) -> EyreResult<()> { let file_path = self.get_da_config_path(config)?; let file_path_str = file_path.to_string_lossy().to_string(); @@ -72,7 +70,12 @@ impl DaClient for EthereumClient { async fn setup(&self, config: &AppChainConfig) -> EyreResult<()> { match get_boolean_input( - format!("Are you running an Anvil node locally? The CLI tool has been tested on Anvil version 0.2.0 (c312c0d). Docs: {}", ANVIL_DOCS).as_str(), + format!( + "Are you running an Anvil node locally? The CLI tool has been tested on Anvil version 0.2.0 \ + (c312c0d). Docs: {}", + ANVIL_DOCS + ) + .as_str(), Some(true), )? { true => Ok(()), diff --git a/src/da/mod.rs b/src/da/mod.rs index b7c44f5..9bc4a96 100644 --- a/src/da/mod.rs +++ b/src/da/mod.rs @@ -2,5 +2,8 @@ pub mod da_layers; pub mod avail; +pub mod celestia; + mod ethereum; + pub mod no_da; diff --git a/src/da/no_da.rs b/src/da/no_da.rs index 6c97157..e8ca1f2 100644 --- a/src/da/no_da.rs +++ b/src/da/no_da.rs @@ -1,12 +1,14 @@ pub struct NoDAConfig; +use async_trait::async_trait; + use crate::app::config::AppChainConfig; use crate::da::da_layers::{DaClient, DaError}; -use async_trait::async_trait; +use eyre::Result as EyreResult; #[async_trait] impl DaClient for NoDAConfig { - fn setup_and_generate_keypair(&self, config: &AppChainConfig) -> Result<(), DaError> { + async fn generate_da_config(&self, config: &AppChainConfig) -> EyreResult<()> { log::info!("Launching {} without any DA mode", config.app_chain); Ok(()) } diff --git a/src/utils/docker.rs b/src/utils/docker.rs index 2d199eb..402ffdd 100644 --- a/src/utils/docker.rs +++ b/src/utils/docker.rs @@ -11,10 +11,11 @@ pub async fn run_docker_image( container_name: &str, env: Option>, host_config: Option, + start_cmd: Option>, ) { is_docker_installed().await; log::info!("🐳 Running docker image: {}", image); - match pull_and_start_docker_image(image, container_name, env, host_config).await { + match pull_and_start_docker_image(image, container_name, env, host_config, start_cmd).await { Ok(..) => { log::debug!("Successfully ran {}", container_name); } @@ -59,6 +60,16 @@ pub async fn container_exists(container_name: &str) -> bool { } } +pub async fn is_container_running(container_name: &str) -> bool { + let docker = Docker::connect_with_local_defaults().unwrap(); + + if let Some(state) = docker.inspect_container(container_name, None).await.unwrap_or_default().state { + return state.running.unwrap_or(false); + } + + false +} + pub async fn kill_container(container_name: &str) -> eyre::Result<()> { let docker = Docker::connect_with_local_defaults().unwrap(); // TODO: handle the error @@ -72,6 +83,7 @@ pub async fn pull_and_start_docker_image( container_name: &str, env: Option>, host_config: Option, + start_cmd: Option>, ) -> Result<(), Box> { let docker = Docker::connect_with_local_defaults().unwrap(); @@ -80,7 +92,7 @@ pub async fn pull_and_start_docker_image( .try_collect::>() .await?; - let config = Config { image: Some(image), tty: Some(true), env, host_config, ..Default::default() }; + let config = Config { image: Some(image), cmd: start_cmd, tty: Some(true), env, host_config, ..Default::default() }; let container_option = Some(CreateContainerOptions { name: container_name, ..Default::default() }); diff --git a/src/utils/errors.rs b/src/utils/errors.rs index 3ab73f2..2cb0112 100644 --- a/src/utils/errors.rs +++ b/src/utils/errors.rs @@ -1,4 +1,5 @@ use std::ffi::OsString; + use thiserror::Error; #[derive(Debug, Error)] diff --git a/src/utils/paths.rs b/src/utils/paths.rs index 98b1843..70bf5d2 100644 --- a/src/utils/paths.rs +++ b/src/utils/paths.rs @@ -5,7 +5,10 @@ use std::path::PathBuf; pub fn get_madara_home() -> Result { if let Some(home_dir) = dirs::home_dir() { let madara_home = home_dir.join(".madara"); + + // Creates the `madara_home` directory if not present fs::create_dir_all(&madara_home)?; + return Ok(madara_home); } @@ -26,7 +29,7 @@ pub fn get_app_home(app: &str) -> Result { let app_chains = get_app_chains_home()?; let app_home = app_chains.join(app); - // Creates the $app_home directory if not present + // Creates the `app_home` directory if not present fs::create_dir_all(&app_home)?; Ok(app_home)