From 3533756f95c4a1ca1fd7ab97c357a73e3c211691 Mon Sep 17 00:00:00 2001 From: glihm Date: Thu, 27 Nov 2025 15:16:38 -0600 Subject: [PATCH 1/2] feat(sozo): add invoke command that doesn't require the world context --- bin/sozo/src/commands/execute.rs | 2 +- bin/sozo/src/commands/invoke.rs | 92 ++++++++++++++++++++++++++++++++ bin/sozo/src/commands/mod.rs | 6 +++ bin/sozo/src/main.rs | 2 + 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 bin/sozo/src/commands/invoke.rs 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..862bc49901 --- /dev/null +++ b/bin/sozo/src/commands/invoke.rs @@ -0,0 +1,92 @@ +use anyhow::{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(value_name = "CONTRACT_ADDRESS", help = "Target contract address.")] + pub contract: Felt, + + #[arg(value_name = "ENTRYPOINT", help = "Entrypoint to invoke on the contract.")] + pub entrypoint: String, + + #[arg( + value_name = "ARG", + num_args = 0.., + help = format!( + "Calldata elements for the entrypoint (space separated).\n\n{}", + CALLDATA_DOC + ) + )] + pub calldata: Vec, + + #[command(flatten)] + pub transaction: TransactionOptions, + + #[command(flatten)] + pub starknet: StarknetOptions, + + #[command(flatten)] + pub account: AccountOptions, +} + +impl InvokeArgs { + pub async fn run(self, ui: &SozoUi) -> Result<()> { + trace!(args = ?self); + + let InvokeArgs { contract, entrypoint, calldata, transaction, starknet, account } = self; + + let account = get_account_from_env(account, &starknet).await?; + let txn_config: TxnConfig = transaction.try_into()?; + + ui.title(format!("Invoke contract {:#066x}", contract)); + ui.step(format!("Entrypoint: {}", entrypoint)); + + let mut decoded = Vec::new(); + for item in calldata { + let felt_values = calldata_decoder::decode_single_calldata(&item) + .with_context(|| format!("Failed to parse calldata argument `{item}`"))?; + decoded.extend(felt_values); + } + + if decoded.is_empty() { + ui.verbose("Calldata: "); + } else { + ui.verbose(format!("Calldata ({} felt(s))", decoded.len())); + } + + let selector = get_selector_from_name(&entrypoint)?; + let call = Call { to: contract, selector, calldata: decoded }; + + let invoker = Invoker::new(account, txn_config); + let result = invoker.invoke(call).await?; + + match result { + dojo_utils::TransactionResult::Noop => { + ui.result("Nothing to invoke (noop)."); + } + dojo_utils::TransactionResult::Hash(hash) => { + ui.result(format!("Invocation sent.\n Tx hash : {hash:#066x}")); + } + dojo_utils::TransactionResult::HashReceipt(hash, receipt) => { + ui.result(format!("Invocation included on-chain.\n Tx hash : {hash:#066x}")); + ui.debug(format!("Receipt: {:?}", receipt)); + } + } + + Ok(()) + } +} 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 { From 71f7cb2b5bc80bce71f98d2d76e6c87b13b1a5b3 Mon Sep 17 00:00:00 2001 From: glihm Date: Thu, 27 Nov 2025 17:26:06 -0600 Subject: [PATCH 2/2] fix: ensure correct calldata decoding --- bin/sozo/src/commands/invoke.rs | 120 ++++++++++++------ .../dojo/world/src/config/calldata_decoder.rs | 21 ++- 2 files changed, 99 insertions(+), 42 deletions(-) diff --git a/bin/sozo/src/commands/invoke.rs b/bin/sozo/src/commands/invoke.rs index 862bc49901..f4ec022238 100644 --- a/bin/sozo/src/commands/invoke.rs +++ b/bin/sozo/src/commands/invoke.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result}; +use anyhow::{anyhow, bail, Context, Result}; use clap::Args; use dojo_utils::{Invoker, TxnConfig}; use dojo_world::config::calldata_decoder; @@ -17,21 +17,16 @@ use crate::utils::{get_account_from_env, CALLDATA_DOC}; world context to be loaded. Use the execute command to execute systems in the \ world context.")] pub struct InvokeArgs { - #[arg(value_name = "CONTRACT_ADDRESS", help = "Target contract address.")] - pub contract: Felt, - - #[arg(value_name = "ENTRYPOINT", help = "Entrypoint to invoke on the contract.")] - pub entrypoint: String, - #[arg( - value_name = "ARG", - num_args = 0.., + num_args = 1.., + required = true, help = format!( - "Calldata elements for the entrypoint (space separated).\n\n{}", + "Calls to invoke, separated by '/'. \ + Each call follows the format [CALLDATA...]\n\n{}", CALLDATA_DOC ) )] - pub calldata: Vec, + pub calls: Vec, #[command(flatten)] pub transaction: TransactionOptions, @@ -39,7 +34,11 @@ pub struct InvokeArgs { #[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, } @@ -47,46 +46,85 @@ impl InvokeArgs { pub async fn run(self, ui: &SozoUi) -> Result<()> { trace!(args = ?self); - let InvokeArgs { contract, entrypoint, calldata, transaction, starknet, account } = 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 account = get_account_from_env(account, &starknet).await?; - let txn_config: TxnConfig = transaction.try_into()?; + let mut calls_iter = self.calls.into_iter(); + let mut call_index = 0usize; - ui.title(format!("Invoke contract {:#066x}", contract)); - ui.step(format!("Entrypoint: {}", entrypoint)); + while let Some(target) = calls_iter.next() { + if matches!(target.as_str(), "/" | "-" | "\\") { + continue; + } - let mut decoded = Vec::new(); - for item in calldata { - let felt_values = calldata_decoder::decode_single_calldata(&item) - .with_context(|| format!("Failed to parse calldata argument `{item}`"))?; - decoded.extend(felt_values); - } + 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); + } + } + } - if decoded.is_empty() { - ui.verbose("Calldata: "); - } else { - ui.verbose(format!("Calldata ({} felt(s))", decoded.len())); - } + 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())); + } - let selector = get_selector_from_name(&entrypoint)?; - let call = Call { to: contract, selector, calldata: decoded }; + invoker.add_call(Call { to: contract_address, selector, calldata }); + } - let invoker = Invoker::new(account, txn_config); - let result = invoker.invoke(call).await?; + if invoker.calls.is_empty() { + bail!("No calls provided to invoke."); + } - match result { - dojo_utils::TransactionResult::Noop => { - ui.result("Nothing to invoke (noop)."); - } - dojo_utils::TransactionResult::Hash(hash) => { - ui.result(format!("Invocation sent.\n Tx hash : {hash:#066x}")); - } - dojo_utils::TransactionResult::HashReceipt(hash, receipt) => { - ui.result(format!("Invocation included on-chain.\n Tx hash : {hash:#066x}")); - ui.debug(format!("Receipt: {:?}", receipt)); + 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/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)?,