diff --git a/Cargo.lock b/Cargo.lock index e579656a60..53b798766b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8006,6 +8006,7 @@ dependencies = [ "thiserror 1.0.63", "tokio", "tracing", + "url", ] [[package]] diff --git a/bin/katana/Cargo.toml b/bin/katana/Cargo.toml index 654221323d..1055a130f9 100644 --- a/bin/katana/Cargo.toml +++ b/bin/katana/Cargo.toml @@ -32,6 +32,7 @@ strum_macros.workspace = true thiserror.workspace = true tokio.workspace = true tracing.workspace = true +url.workspace = true [dev-dependencies] assert_matches.workspace = true diff --git a/bin/katana/src/cli/init/mod.rs b/bin/katana/src/cli/init/mod.rs index 7658e8dd63..2fe0c2011c 100644 --- a/bin/katana/src/cli/init/mod.rs +++ b/bin/katana/src/cli/init/mod.rs @@ -1,11 +1,8 @@ -mod deployment; - use std::str::FromStr; use std::sync::Arc; -use anyhow::{Context, Result}; +use anyhow::Context; use clap::Args; -use inquire::{Confirm, CustomType, Select}; use katana_chain_spec::rollup::FeeContract; use katana_chain_spec::{rollup, SettlementLayer}; use katana_primitives::chain::ChainId; @@ -14,35 +11,58 @@ use katana_primitives::genesis::constant::DEFAULT_PREFUNDED_ACCOUNT_BALANCE; use katana_primitives::genesis::Genesis; use katana_primitives::{ContractAddress, Felt, U256}; use lazy_static::lazy_static; +use prompt::CARTRIDGE_SN_SEPOLIA_PROVIDER; use starknet::accounts::{ExecutionEncoding, SingleOwnerAccount}; -use starknet::core::types::{BlockId, BlockTag}; use starknet::core::utils::{cairo_short_string_to_felt, parse_cairo_short_string}; use starknet::providers::jsonrpc::HttpTransport; -use starknet::providers::{JsonRpcClient, Provider, Url}; -use starknet::signers::{LocalWallet, SigningKey}; -use tokio::runtime::Runtime as AsyncRuntime; +use starknet::providers::{JsonRpcClient, Provider}; +use starknet::signers::SigningKey; +use url::Url; -const CARTRIDGE_SN_SEPOLIA_PROVIDER: &str = "https://api.cartridge.gg/x/starknet/sepolia"; +mod deployment; +mod prompt; #[derive(Debug, Args)] -pub struct InitArgs; +pub struct InitArgs { + #[arg(long)] + #[arg(requires_all = ["settlement_chain", "settlement_account", "settlement_account_private_key"])] + id: Option, + + #[arg(long = "settlement-chain")] + #[arg(requires_all = ["id", "settlement_account", "settlement_account_private_key"])] + settlement_chain: Option, + + #[arg(long = "settlement-account-address")] + #[arg(requires_all = ["id", "settlement_chain", "settlement_account_private_key"])] + settlement_account: Option, + + #[arg(long = "settlement-account-private-key")] + #[arg(requires_all = ["id", "settlement_chain", "settlement_account"])] + settlement_account_private_key: Option, + + #[arg(long = "settlement-contract")] + #[arg(requires_all = ["id", "settlement_chain", "settlement_account", "settlement_account_private_key"])] + settlement_contract: Option, +} impl InitArgs { // TODO: // - deploy bridge contract - // - generate the genesis - pub(crate) fn execute(self) -> Result<()> { - let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build()?; - let input = self.prompt(&rt)?; + pub(crate) async fn execute(self) -> anyhow::Result<()> { + let output = if let Some(output) = self.process_args().await { + output? + } else { + prompt::prompt().await? + }; let settlement = SettlementLayer::Starknet { - account: input.account, - rpc_url: input.rpc_url, - id: ChainId::parse(&input.settlement_id)?, - core_contract: input.settlement_contract, + account: output.account, + rpc_url: output.rpc_url, + id: ChainId::parse(&output.settlement_id)?, + core_contract: output.settlement_contract, }; - let id = ChainId::parse(&input.id)?; + let id = ChainId::parse(&output.id)?; let genesis = GENESIS.clone(); // At the moment, the fee token is limited to a predefined token. let fee_contract = FeeContract::default(); @@ -53,134 +73,75 @@ impl InitArgs { Ok(()) } - fn prompt(&self, rt: &AsyncRuntime) -> Result { - let chain_id = CustomType::::new("Id") - .with_help_message("This will be the id of your rollup chain.") - // checks that the input is a valid ascii string. - .with_parser(&|input| { - if input.is_ascii() { - Ok(input.to_string()) - } else { - Err(()) - } - }) - .with_error_message("Must be valid ASCII characters") - .prompt()?; - - #[derive(Debug, strum_macros::Display)] - enum SettlementChainOpt { - Sepolia, - #[cfg(feature = "init-custom-settlement-chain")] - Custom, - } - - // Right now we only support settling on Starknet Sepolia because we're limited to what - // network the Atlantic service could settle the proofs to. Supporting a custom - // network here (eg local devnet) would require that the proving service we're using - // be able to settle the proofs there. - let network_opts = vec![ - SettlementChainOpt::Sepolia, - #[cfg(feature = "init-custom-settlement-chain")] - SettlementChainOpt::Custom, - ]; - - let network_type = Select::new("Settlement chain", network_opts).prompt()?; - - let settlement_url = match network_type { - SettlementChainOpt::Sepolia => Url::parse(CARTRIDGE_SN_SEPOLIA_PROVIDER)?, - - // Useful for testing the program flow without having to run it against actual network. - #[cfg(feature = "init-custom-settlement-chain")] - SettlementChainOpt::Custom => CustomType::::new("Settlement RPC URL") - .with_default(Url::parse("http://localhost:5050")?) - .with_error_message("Please enter a valid URL") - .prompt()?, - }; - - let l1_provider = Arc::new(JsonRpcClient::new(HttpTransport::new(settlement_url.clone()))); - - let contract_exist_parser = &|input: &str| { - let block_id = BlockId::Tag(BlockTag::Pending); - let address = Felt::from_str(input).map_err(|_| ())?; - let result = rt.block_on(l1_provider.clone().get_class_hash_at(block_id, address)); - - match result { - Ok(..) => Ok(ContractAddress::from(address)), - Err(..) => Err(()), - } - }; + async fn process_args(&self) -> Option> { + // Here we just check that if `id` is present, then all the other required* arguments must + // be present as well. This is guaranteed by `clap`. + if let Some(id) = self.id.clone() { + // These args are all required if at least one of them are specified (incl chain id) and + // `clap` has already handled that for us, so it's safe to unwrap here. + let settlement_chain = self.settlement_chain.clone().expect("must present"); + let settlement_account_address = self.settlement_account.expect("must present"); + let settlement_private_key = self.settlement_account_private_key.expect("must present"); + + let settlement_url = match settlement_chain { + SettlementChain::Sepolia => Url::parse(CARTRIDGE_SN_SEPOLIA_PROVIDER).unwrap(), + #[cfg(feature = "init-custom-settlement-chain")] + SettlementChain::Custom(url) => url, + }; - let account_address = CustomType::::new("Account") - .with_error_message("Please enter a valid account address") - .with_parser(contract_exist_parser) - .prompt()?; - - let private_key = CustomType::::new("Private key") - .with_formatter(&|input: Felt| format!("{input:#x}")) - .prompt()?; - - let l1_chain_id = rt.block_on(l1_provider.chain_id())?; - let account = SingleOwnerAccount::new( - l1_provider.clone(), - LocalWallet::from_signing_key(SigningKey::from_secret_scalar(private_key)), - account_address.into(), - l1_chain_id, - ExecutionEncoding::New, - ); + let l1_provider = + Arc::new(JsonRpcClient::new(HttpTransport::new(settlement_url.clone()))); + let l1_chain_id = l1_provider.chain_id().await.unwrap(); - // The core settlement contract on L1c. - // Prompt the user whether to deploy the settlement contract or not. - let settlement_contract = - if Confirm::new("Deploy settlement contract?").with_default(true).prompt()? { - let chain_id = cairo_short_string_to_felt(&chain_id)?; - let initialize = deployment::deploy_settlement_contract(account, chain_id); - let result = rt.block_on(initialize); - result? + let settlement_contract = if let Some(contract) = self.settlement_contract { + let chain_id = cairo_short_string_to_felt(&id).unwrap(); + deployment::check_program_info(chain_id, contract.into(), &l1_provider) + .await + .unwrap(); + contract } - // If denied, prompt the user for an already deployed contract. + // If settlement contract is not provided, then we will deploy it. else { - let address = CustomType::::new("Settlement contract") - .with_parser(contract_exist_parser) - .prompt()?; - - // Check that the settlement contract has been initialized with the correct program - // info. - let chain_id = cairo_short_string_to_felt(&chain_id)?; - rt.block_on(deployment::check_program_info(chain_id, address.into(), &l1_provider)) - .context( - "Invalid settlement contract. The contract might have been configured \ - incorrectly.", - )?; - - address + let account = SingleOwnerAccount::new( + l1_provider, + SigningKey::from_secret_scalar(settlement_private_key).into(), + settlement_account_address.into(), + l1_chain_id, + ExecutionEncoding::New, + ); + + deployment::deploy_settlement_contract(account, l1_chain_id).await.unwrap() }; - Ok(PromptOutcome { - account: account_address, - settlement_contract, - settlement_id: parse_cairo_short_string(&l1_chain_id)?, - id: chain_id, - rpc_url: settlement_url, - }) + Some(Ok(Outcome { + id, + settlement_contract, + rpc_url: settlement_url, + account: settlement_account_address, + settlement_id: parse_cairo_short_string(&l1_chain_id).unwrap(), + })) + } else { + None + } } } #[derive(Debug)] -struct PromptOutcome { +struct Outcome { /// the account address that is used to send the transactions for contract /// deployment/initialization. - account: ContractAddress, + pub account: ContractAddress, // the id of the new chain to be initialized. - id: String, + pub id: String, // the chain id of the settlement layer. - settlement_id: String, + pub settlement_id: String, // the rpc url for the settlement layer. - rpc_url: Url, + pub rpc_url: Url, - settlement_contract: ContractAddress, + pub settlement_contract: ContractAddress, } lazy_static! { @@ -192,3 +153,77 @@ lazy_static! { genesis }; } + +#[derive(Debug, thiserror::Error)] +#[error("Unsupported settlement chain: {id}")] +struct SettlementChainTryFromStrError { + id: String, +} + +#[derive(Debug, Clone, strum_macros::Display)] +enum SettlementChain { + Sepolia, + #[cfg(feature = "init-custom-settlement-chain")] + Custom(Url), +} + +impl std::str::FromStr for SettlementChain { + type Err = SettlementChainTryFromStrError; + fn from_str(s: &str) -> Result::Err> { + let id = s.to_lowercase(); + if &id == "sepolia" || &id == "sn_sepolia" { + return Ok(SettlementChain::Sepolia); + } + + #[cfg(feature = "init-custom-settlement-chain")] + if let Ok(url) = Url::parse(s) { + return Ok(SettlementChain::Custom(url)); + }; + + Err(SettlementChainTryFromStrError { id: s.to_string() }) + } +} + +impl TryFrom<&str> for SettlementChain { + type Error = SettlementChainTryFromStrError; + fn try_from(s: &str) -> Result>::Error> { + SettlementChain::from_str(s) + } +} + +#[cfg(test)] +mod tests { + use assert_matches::assert_matches; + + use super::*; + + #[test] + fn sepolia_from_str() { + assert_matches!(SettlementChain::from_str("sepolia"), Ok(SettlementChain::Sepolia)); + assert_matches!(SettlementChain::from_str("SEPOLIA"), Ok(SettlementChain::Sepolia)); + assert_matches!(SettlementChain::from_str("sn_sepolia"), Ok(SettlementChain::Sepolia)); + assert_matches!(SettlementChain::from_str("SN_SEPOLIA"), Ok(SettlementChain::Sepolia)); + } + + #[test] + fn invalid_chain() { + assert!(SettlementChain::from_str("invalid_chain").is_err()); + } + + #[test] + fn try_from_str() { + assert!(matches!(SettlementChain::try_from("sepolia"), Ok(SettlementChain::Sepolia))); + assert!(SettlementChain::try_from("invalid").is_err(),); + } + + #[test] + #[cfg(feature = "init-custom-settlement-chain")] + fn custom_settlement_chain() { + assert_matches!( + SettlementChain::from_str("http://localhost:5050"), + Ok(SettlementChain::Custom(actual_url)) => { + assert_eq!(actual_url, Url::parse("http://localhost:5050").unwrap()); + } + ); + } +} diff --git a/bin/katana/src/cli/init/prompt.rs b/bin/katana/src/cli/init/prompt.rs new file mode 100644 index 0000000000..60283d8072 --- /dev/null +++ b/bin/katana/src/cli/init/prompt.rs @@ -0,0 +1,128 @@ +use std::str::FromStr; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use inquire::{Confirm, CustomType, Select}; +use katana_primitives::{ContractAddress, Felt}; +use starknet::accounts::{ExecutionEncoding, SingleOwnerAccount}; +use starknet::core::types::{BlockId, BlockTag}; +use starknet::core::utils::{cairo_short_string_to_felt, parse_cairo_short_string}; +use starknet::providers::jsonrpc::HttpTransport; +use starknet::providers::{JsonRpcClient, Provider, Url}; +use starknet::signers::{LocalWallet, SigningKey}; +use tokio::runtime::Handle; + +use super::{deployment, Outcome}; + +pub const CARTRIDGE_SN_SEPOLIA_PROVIDER: &str = "https://api.cartridge.gg/x/starknet/sepolia"; + +pub async fn prompt() -> Result { + let chain_id = CustomType::::new("Id") + .with_help_message("This will be the id of your rollup chain.") + // checks that the input is a valid ascii string. + .with_parser(&|input| { + if !input.is_empty() && input.is_ascii() { + Ok(input.to_string()) + } else { + Err(()) + } + }) + .with_error_message("Must be valid ASCII characters") + .prompt()?; + + #[derive(Debug, Clone, strum_macros::Display)] + enum SettlementChainOpt { + Sepolia, + #[cfg(feature = "init-custom-settlement-chain")] + Custom, + } + + // Right now we only support settling on Starknet Sepolia because we're limited to what + // network the Atlantic service could settle the proofs to. Supporting a custom + // network here (eg local devnet) would require that the proving service we're using + // be able to settle the proofs there. + let network_opts = vec![ + SettlementChainOpt::Sepolia, + #[cfg(feature = "init-custom-settlement-chain")] + SettlementChainOpt::Custom, + ]; + + let network_type = Select::new("Settlement chain", network_opts) + .with_help_message("This is the chain where the rollup will settle on.") + .prompt()?; + + let settlement_url = match network_type { + SettlementChainOpt::Sepolia => Url::parse(CARTRIDGE_SN_SEPOLIA_PROVIDER)?, + + // Useful for testing the program flow without having to run it against actual network. + #[cfg(feature = "init-custom-settlement-chain")] + SettlementChainOpt::Custom => CustomType::::new("Settlement RPC URL") + .with_default(Url::parse("http://localhost:5050")?) + .with_error_message("Please enter a valid URL") + .prompt()?, + }; + + let l1_provider = Arc::new(JsonRpcClient::new(HttpTransport::new(settlement_url.clone()))); + + let contract_exist_parser = &|input: &str| { + let block_id = BlockId::Tag(BlockTag::Pending); + let address = Felt::from_str(input).map_err(|_| ())?; + let result = tokio::task::block_in_place(|| { + Handle::current().block_on(l1_provider.clone().get_class_hash_at(block_id, address)) + }); + + match result { + Ok(..) => Ok(ContractAddress::from(address)), + Err(..) => Err(()), + } + }; + + let account_address = CustomType::::new("Account") + .with_error_message("Please enter a valid account address") + .with_parser(contract_exist_parser) + .prompt()?; + + let private_key = CustomType::::new("Private key") + .with_formatter(&|input: Felt| format!("{input:#x}")) + .prompt()?; + + let l1_chain_id = l1_provider.chain_id().await?; + let account = SingleOwnerAccount::new( + l1_provider.clone(), + LocalWallet::from_signing_key(SigningKey::from_secret_scalar(private_key)), + account_address.into(), + l1_chain_id, + ExecutionEncoding::New, + ); + + // The core settlement contract on L1c. + // Prompt the user whether to deploy the settlement contract or not. + let settlement_contract = + if Confirm::new("Deploy settlement contract?").with_default(true).prompt()? { + let chain_id = cairo_short_string_to_felt(&chain_id)?; + deployment::deploy_settlement_contract(account, chain_id).await? + } + // If denied, prompt the user for an already deployed contract. + else { + let address = CustomType::::new("Settlement contract") + .with_parser(contract_exist_parser) + .prompt()?; + + // Check that the settlement contract has been initialized with the correct program + // info. + let chain_id = cairo_short_string_to_felt(&chain_id)?; + deployment::check_program_info(chain_id, address.into(), &l1_provider).await.context( + "Invalid settlement contract. The contract might have been configured incorrectly.", + )?; + + address + }; + + Ok(Outcome { + id: chain_id, + settlement_contract, + account: account_address, + rpc_url: settlement_url, + settlement_id: parse_cairo_short_string(&l1_chain_id)?, + }) +} diff --git a/bin/katana/src/cli/mod.rs b/bin/katana/src/cli/mod.rs index 0ea7192695..8f5fbfb483 100644 --- a/bin/katana/src/cli/mod.rs +++ b/bin/katana/src/cli/mod.rs @@ -1,8 +1,11 @@ -use anyhow::Result; +use std::future::Future; + +use anyhow::{Context, Result}; use clap::{Args, CommandFactory, Parser, Subcommand}; use clap_complete::Shell; use katana_cli::NodeArgs; use katana_node::version::VERSION; +use tokio::runtime::Runtime; mod config; mod db; @@ -22,14 +25,14 @@ impl Cli { pub fn run(self) -> Result<()> { if let Some(cmd) = self.commands { return match cmd { - Commands::Completions(args) => args.execute(), Commands::Db(args) => args.execute(), - Commands::Init(args) => args.execute(), Commands::Config(args) => args.execute(), + Commands::Completions(args) => args.execute(), + Commands::Init(args) => execute_async(args.execute())?, }; } - self.node.with_config_file()?.execute() + execute_async(self.node.with_config_file()?.execute())? } } @@ -61,3 +64,11 @@ impl CompletionsArgs { Ok(()) } } + +pub fn execute_async(future: F) -> Result { + Ok(build_tokio_runtime().context("Failed to build tokio runtime")?.block_on(future)) +} + +fn build_tokio_runtime() -> std::io::Result { + tokio::runtime::Builder::new_multi_thread().enable_all().build() +} diff --git a/crates/katana/cli/src/args.rs b/crates/katana/cli/src/args.rs index 9f3390fa87..d35df98985 100644 --- a/crates/katana/cli/src/args.rs +++ b/crates/katana/cli/src/args.rs @@ -113,13 +113,9 @@ pub struct NodeArgs { } impl NodeArgs { - pub fn execute(&self) -> Result<()> { + pub async fn execute(&self) -> Result<()> { self.init_logging()?; - tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .context("failed to build tokio runtime")? - .block_on(self.start_node()) + self.start_node().await } async fn start_node(&self) -> Result<()> {