Skip to content

Commit

Permalink
feat(katana): non-interactive init (#2988)
Browse files Browse the repository at this point in the history
If no arguments are provided, `katana init` retains its interactive prompt (current behavior). This PR adds a non-interactive flow where all required values can be passed directly via CLI options to skip the prompts.
  • Loading branch information
kariy authored Feb 3, 2025
1 parent bdefea2 commit bf0c5ee
Show file tree
Hide file tree
Showing 6 changed files with 310 additions and 138 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions bin/katana/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
291 changes: 163 additions & 128 deletions bin/katana/src/cli/init/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<String>,

#[arg(long = "settlement-chain")]
#[arg(requires_all = ["id", "settlement_account", "settlement_account_private_key"])]
settlement_chain: Option<SettlementChain>,

#[arg(long = "settlement-account-address")]
#[arg(requires_all = ["id", "settlement_chain", "settlement_account_private_key"])]
settlement_account: Option<ContractAddress>,

#[arg(long = "settlement-account-private-key")]
#[arg(requires_all = ["id", "settlement_chain", "settlement_account"])]
settlement_account_private_key: Option<Felt>,

#[arg(long = "settlement-contract")]
#[arg(requires_all = ["id", "settlement_chain", "settlement_account", "settlement_account_private_key"])]
settlement_contract: Option<ContractAddress>,
}

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();
Expand All @@ -53,134 +73,75 @@ impl InitArgs {
Ok(())
}

fn prompt(&self, rt: &AsyncRuntime) -> Result<PromptOutcome> {
let chain_id = CustomType::<String>::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::<Url>::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<anyhow::Result<Outcome>> {
// 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::<ContractAddress>::new("Account")
.with_error_message("Please enter a valid account address")
.with_parser(contract_exist_parser)
.prompt()?;

let private_key = CustomType::<Felt>::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::<ContractAddress>::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! {
Expand All @@ -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<SettlementChain, <Self as ::core::str::FromStr>::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<SettlementChain, <Self as TryFrom<&str>>::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());
}
);
}
}
Loading

0 comments on commit bf0c5ee

Please sign in to comment.