diff --git a/bin/sozo/src/commands/execute.rs b/bin/sozo/src/commands/execute.rs index 8b2814bacb..2d9cc8f2c3 100644 --- a/bin/sozo/src/commands/execute.rs +++ b/bin/sozo/src/commands/execute.rs @@ -19,7 +19,7 @@ use super::options::world::WorldOptions; use crate::utils::{self, CALLDATA_DOC}; #[derive(Debug, Args)] -#[command(about = "Execute one or several systems with the given calldata.")] +#[command(about = "Execute one or several systems in the world context with the given calldata.")] pub struct ExecuteArgs { #[arg(num_args = 1..)] #[arg(required = true)] diff --git a/bin/sozo/src/commands/invoke.rs b/bin/sozo/src/commands/invoke.rs new file mode 100644 index 0000000000..f4ec022238 --- /dev/null +++ b/bin/sozo/src/commands/invoke.rs @@ -0,0 +1,130 @@ +use anyhow::{anyhow, bail, Context, Result}; +use clap::Args; +use dojo_utils::{Invoker, TxnConfig}; +use dojo_world::config::calldata_decoder; +use sozo_ui::SozoUi; +use starknet::core::types::{Call, Felt}; +use starknet::core::utils::get_selector_from_name; +use tracing::trace; + +use super::options::account::AccountOptions; +use super::options::starknet::StarknetOptions; +use super::options::transaction::TransactionOptions; +use crate::utils::{get_account_from_env, CALLDATA_DOC}; + +#[derive(Debug, Args)] +#[command(about = "Invoke a contract entrypoint on Starknet. This command does not require the \ + world context to be loaded. Use the execute command to execute systems in the \ + world context.")] +pub struct InvokeArgs { + #[arg( + num_args = 1.., + required = true, + help = format!( + "Calls to invoke, separated by '/'. \ + Each call follows the format [CALLDATA...]\n\n{}", + CALLDATA_DOC + ) + )] + pub calls: Vec, + + #[command(flatten)] + pub transaction: TransactionOptions, + + #[command(flatten)] + pub starknet: StarknetOptions, + + #[arg(long, default_value = "0x0", help = "Selector for the entrypoint in felt form.")] + pub selector: Option, + + #[command(flatten)] + #[command(next_help_heading = "Account options")] + pub account: AccountOptions, +} + +impl InvokeArgs { + pub async fn run(self, ui: &SozoUi) -> Result<()> { + trace!(args = ?self); + + let account = get_account_from_env(self.account, &self.starknet).await?; + let txn_config: TxnConfig = self.transaction.try_into()?; + let mut invoker = Invoker::new(account, txn_config); + + let mut calls_iter = self.calls.into_iter(); + let mut call_index = 0usize; + + while let Some(target) = calls_iter.next() { + if matches!(target.as_str(), "/" | "-" | "\\") { + continue; + } + + let entrypoint = calls_iter.next().ok_or_else(|| { + anyhow!( + "Missing entrypoint for target `{target}`. Provide calls as ` \ + [CALLDATA...]`." + ) + })?; + + let contract_address = parse_contract_address(&target)?; + let selector = get_selector_from_name(&entrypoint)?; + + let mut calldata = Vec::new(); + for arg in calls_iter.by_ref() { + match arg.as_str() { + "/" | "-" | "\\" => break, + _ => { + let felts = + calldata_decoder::decode_single_calldata(&arg).with_context(|| { + format!("Failed to parse calldata argument `{arg}`") + })?; + calldata.extend(felts); + } + } + } + + call_index += 1; + ui.step(format!("Call #{call_index}: {entrypoint} @ {:#066x}", contract_address)); + if calldata.is_empty() { + ui.verbose(" Calldata: "); + } else { + ui.verbose(format!(" Calldata ({} felt(s))", calldata.len())); + } + + invoker.add_call(Call { to: contract_address, selector, calldata }); + } + + if invoker.calls.is_empty() { + bail!("No calls provided to invoke."); + } + + let results = invoker.multicall().await?; + + for (idx, result) in results.iter().enumerate() { + let display_idx = idx + 1; + match result { + dojo_utils::TransactionResult::Noop => { + ui.result(format!("Call #{display_idx} noop (no transaction sent).")); + } + dojo_utils::TransactionResult::Hash(hash) => { + ui.result(format!("Call #{display_idx} sent.\n Tx hash : {hash:#066x}")); + } + dojo_utils::TransactionResult::HashReceipt(hash, receipt) => { + ui.result(format!("Call #{display_idx} included.\n Tx hash : {hash:#066x}")); + ui.debug(format!("Receipt: {:?}", receipt)); + } + } + } + + Ok(()) + } +} + +fn parse_contract_address(value: &str) -> Result { + if let Ok(felt) = Felt::from_hex(value) { + return Ok(felt); + } + + Felt::from_dec_str(value).map_err(|_| { + anyhow!("Invalid contract address `{value}`. Use hex (0x...) or decimal form.") + }) +} diff --git a/bin/sozo/src/commands/mod.rs b/bin/sozo/src/commands/mod.rs index fccbe84e6c..6647bf1922 100644 --- a/bin/sozo/src/commands/mod.rs +++ b/bin/sozo/src/commands/mod.rs @@ -18,6 +18,7 @@ pub(crate) mod execute; pub(crate) mod hash; pub(crate) mod init; pub(crate) mod inspect; +pub(crate) mod invoke; pub(crate) mod mcp; pub(crate) mod migrate; pub(crate) mod model; @@ -37,6 +38,7 @@ use execute::ExecuteArgs; use hash::HashArgs; use init::InitArgs; use inspect::InspectArgs; +use invoke::InvokeArgs; use mcp::McpArgs; use migrate::MigrateArgs; use model::ModelArgs; @@ -59,6 +61,8 @@ pub enum Commands { Events(Box), #[command(about = "Execute one or several systems with the given calldata.")] Execute(Box), + #[command(about = "Invoke a contract entrypoint on Starknet.")] + Invoke(Box), #[command(about = "Clean the build directory")] Clean(Box), #[command(about = "Computes hash with different hash functions")] @@ -98,6 +102,7 @@ impl fmt::Display for Commands { Commands::Events(_) => write!(f, "Events"), Commands::Execute(_) => write!(f, "Execute"), Commands::Hash(_) => write!(f, "Hash"), + Commands::Invoke(_) => write!(f, "Invoke"), Commands::Declare(_) => write!(f, "Declare"), Commands::Deploy(_) => write!(f, "Deploy"), Commands::Init(_) => write!(f, "Init"), @@ -126,6 +131,7 @@ pub async fn run(command: Commands, scarb_metadata: &Metadata, ui: &SozoUi) -> R Commands::Clean(args) => args.run(scarb_metadata), Commands::Events(args) => args.run(scarb_metadata, ui).await, Commands::Execute(args) => args.run(scarb_metadata, ui).await, + Commands::Invoke(args) => args.run(ui).await, Commands::Hash(args) => args.run(scarb_metadata), Commands::Declare(args) => args.run(ui).await, Commands::Deploy(args) => args.run(ui).await, diff --git a/bin/sozo/src/main.rs b/bin/sozo/src/main.rs index 34f3c42ff1..7155674ca7 100644 --- a/bin/sozo/src/main.rs +++ b/bin/sozo/src/main.rs @@ -47,6 +47,8 @@ async fn cli_main(args: SozoArgs, ui: &SozoUi) -> Result<()> { args.run(ui).await } else if let Commands::Deploy(args) = args.command { args.run(ui).await + } else if let Commands::Invoke(args) = args.command { + args.run(ui).await } else { // Default to the current directory to mimic how Scarb works. let manifest_path = if let Some(manifest_path) = &args.manifest_path { diff --git a/crates/dojo/world/src/config/calldata_decoder.rs b/crates/dojo/world/src/config/calldata_decoder.rs index fb924d0ee8..34bf9af7fd 100644 --- a/crates/dojo/world/src/config/calldata_decoder.rs +++ b/crates/dojo/world/src/config/calldata_decoder.rs @@ -2,7 +2,7 @@ use anyhow::{self, Result}; use cainome::cairo_serde::{ByteArray, CairoSerde}; use num_bigint::BigUint; use starknet::core::types::{Felt, FromStrError}; -use starknet::core::utils::cairo_short_string_to_felt; +use starknet::core::utils::{cairo_short_string_to_felt, get_selector_from_name}; /// An error that occurs while decoding calldata. #[derive(thiserror::Error, Debug)] @@ -33,6 +33,24 @@ trait CalldataDecoder { fn decode(&self, input: &str) -> DecoderResult>; } +/// Decodes a selector into a [`Felt`]. +struct SelectorCalldataDecoder; +impl CalldataDecoder for SelectorCalldataDecoder { + fn decode(&self, input: &str) -> DecoderResult> { + let felt_selector = match get_selector_from_name(input) { + Ok(felt) => felt, + Err(_) => { + return Err(CalldataDecoderError::ParseError(format!( + "Selector `{}` contains non-ASCII characters", + input + ))); + } + }; + + Ok(vec![felt_selector]) + } +} + /// Decodes a u256 string into a [`Felt`]s array representing /// a u256 value split into two 128-bit words. struct U256CalldataDecoder; @@ -224,6 +242,7 @@ pub fn decode_single_calldata(item: &str) -> DecoderResult> { let felts = if let Some((prefix, value)) = item.split_once(ITEM_PREFIX_DELIMITER) { match prefix { + "selector" => SelectorCalldataDecoder.decode(value)?, "u256" => U256CalldataDecoder.decode(value)?, "str" => StrCalldataDecoder.decode(value)?, "sstr" => ShortStrCalldataDecoder.decode(value)?,