diff --git a/bin/sozo/src/args.rs b/bin/sozo/src/args.rs index 4a065764a9..4dbc54003d 100644 --- a/bin/sozo/src/args.rs +++ b/bin/sozo/src/args.rs @@ -9,6 +9,7 @@ use tracing_log::AsTrace; use crate::commands::auth::AuthArgs; use crate::commands::build::BuildArgs; +use crate::commands::call::CallArgs; use crate::commands::clean::CleanArgs; use crate::commands::completions::CompletionsArgs; use crate::commands::dev::DevArgs; @@ -79,6 +80,8 @@ pub enum Commands { Test(TestArgs), #[command(about = "Execute a world's system")] Execute(ExecuteArgs), + #[command(about = "Call a world's system")] + Call(CallArgs), #[command(about = "Interact with a worlds models")] Model(ModelArgs), #[command(about = "Register new models")] diff --git a/bin/sozo/src/commands/call.rs b/bin/sozo/src/commands/call.rs new file mode 100644 index 0000000000..109f8af41c --- /dev/null +++ b/bin/sozo/src/commands/call.rs @@ -0,0 +1,56 @@ +use anyhow::Result; +use clap::Args; +use scarb::core::Config; +use starknet::core::types::FieldElement; + +use super::options::starknet::StarknetOptions; +use super::options::world::WorldOptions; +use crate::utils; + +#[derive(Debug, Args)] +#[command(about = "Call a system with the given calldata.")] +pub struct CallArgs { + #[arg(help = "The address or the fully qualified name of the contract to call.")] + pub contract: String, + + #[arg(help = "The name of the entrypoint to call.")] + pub entrypoint: String, + + #[arg(short, long)] + #[arg(value_delimiter = ',')] + #[arg(help = "The calldata to be passed to the entrypoint. Comma separated values e.g., \ + 0x12345,0x69420.")] + pub calldata: Vec, + + #[arg(short, long)] + #[arg(help = "The block ID (could be a hash, a number, 'pending' or 'latest')")] + pub block_id: Option, + + #[command(flatten)] + pub starknet: StarknetOptions, + + #[command(flatten)] + pub world: WorldOptions, +} + +impl CallArgs { + pub fn run(self, config: &Config) -> Result<()> { + let env_metadata = utils::load_metadata_from_config(config)?; + + config.tokio_handle().block_on(async { + let world_reader = + utils::world_reader_from_env_metadata(self.world, self.starknet, &env_metadata) + .await + .unwrap(); + + sozo_ops::call::call( + world_reader, + self.contract, + self.entrypoint, + self.calldata, + self.block_id, + ) + .await + }) + } +} diff --git a/bin/sozo/src/commands/execute.rs b/bin/sozo/src/commands/execute.rs index a777dd19b0..663290ad85 100644 --- a/bin/sozo/src/commands/execute.rs +++ b/bin/sozo/src/commands/execute.rs @@ -22,7 +22,7 @@ pub struct ExecuteArgs { #[arg(short, long)] #[arg(value_delimiter = ',')] - #[arg(help = "The calldata to be passed to the system. Comma seperated values e.g., \ + #[arg(help = "The calldata to be passed to the system. Comma separated values e.g., \ 0x12345,0x69420.")] pub calldata: Vec, diff --git a/bin/sozo/src/commands/mod.rs b/bin/sozo/src/commands/mod.rs index fce588da4f..d0f563bce4 100644 --- a/bin/sozo/src/commands/mod.rs +++ b/bin/sozo/src/commands/mod.rs @@ -5,6 +5,7 @@ use crate::args::Commands; pub(crate) mod auth; pub(crate) mod build; +pub(crate) mod call; pub(crate) mod clean; pub(crate) mod completions; pub(crate) mod dev; @@ -27,6 +28,7 @@ pub fn run(command: Commands, config: &Config) -> Result<()> { Commands::Dev(args) => args.run(config), Commands::Auth(args) => args.run(config), Commands::Execute(args) => args.run(config), + Commands::Call(args) => args.run(config), Commands::Model(args) => args.run(config), Commands::Register(args) => args.run(config), Commands::Events(args) => args.run(config), diff --git a/bin/sozo/src/utils.rs b/bin/sozo/src/utils.rs index 76d6de797b..8bd219e5b7 100644 --- a/bin/sozo/src/utils.rs +++ b/bin/sozo/src/utils.rs @@ -1,5 +1,6 @@ use anyhow::Error; use dojo_world::contracts::world::WorldContract; +use dojo_world::contracts::WorldContractReader; use dojo_world::metadata::{dojo_metadata_from_workspace, Environment}; use scarb::core::Config; use starknet::accounts::SingleOwnerAccount; @@ -11,6 +12,15 @@ use crate::commands::options::account::AccountOptions; use crate::commands::options::starknet::StarknetOptions; use crate::commands::options::world::WorldOptions; +/// Load metadata from the Scarb configuration. +/// +/// # Arguments +/// +/// * `config` - Scarb project configuration. +/// +/// # Returns +/// +/// A [`Environment`] on success. pub fn load_metadata_from_config(config: &Config) -> Result, Error> { let env_metadata = if config.manifest_path().exists() { let ws = scarb::ops::read_workspace(config.manifest_path(), config)?; @@ -23,6 +33,18 @@ pub fn load_metadata_from_config(config: &Config) -> Result, Ok(env_metadata) } +/// Build a world contract from the provided environment. +/// +/// # Arguments +/// +/// * `world` - The world options such as the world address, +/// * `account` - The account options, +/// * `starknet` - The Starknet options such as the RPC url, +/// * `env_metadata` - Optional environment coming from Scarb configuration. +/// +/// # Returns +/// +/// A [`WorldContract`] on success. pub async fn world_from_env_metadata( world: WorldOptions, account: AccountOptions, @@ -35,3 +57,25 @@ pub async fn world_from_env_metadata( let account = account.account(provider, env_metadata.as_ref()).await?; Ok(WorldContract::new(world_address, account)) } + +/// Build a world contract reader from the provided environment. +/// +/// # Arguments +/// +/// * `world` - The world options such as the world address, +/// * `starknet` - The Starknet options such as the RPC url, +/// * `env_metadata` - Optional environment coming from Scarb configuration. +/// +/// # Returns +/// +/// A [`WorldContractReader`] on success. +pub async fn world_reader_from_env_metadata( + world: WorldOptions, + starknet: StarknetOptions, + env_metadata: &Option, +) -> Result>, Error> { + let world_address = world.address(env_metadata.as_ref())?; + let provider = starknet.provider(env_metadata.as_ref())?; + + Ok(WorldContractReader::new(world_address, provider)) +} diff --git a/crates/dojo-test-utils/src/sequencer.rs b/crates/dojo-test-utils/src/sequencer.rs index ba61129898..8a72922a06 100644 --- a/crates/dojo-test-utils/src/sequencer.rs +++ b/crates/dojo-test-utils/src/sequencer.rs @@ -101,6 +101,10 @@ impl TestSequencer { ) } + pub fn provider(&self) -> JsonRpcClient { + JsonRpcClient::new(HttpTransport::new(self.url.clone())) + } + pub fn account_at_index( &self, index: usize, diff --git a/crates/sozo/ops/src/call.rs b/crates/sozo/ops/src/call.rs new file mode 100644 index 0000000000..14bb487b5b --- /dev/null +++ b/crates/sozo/ops/src/call.rs @@ -0,0 +1,39 @@ +use anyhow::{Context, Result}; +use dojo_world::contracts::WorldContractReader; +use starknet::core::types::{BlockId, BlockTag, FieldElement, FunctionCall}; +use starknet::core::utils::get_selector_from_name; +use starknet::providers::Provider; + +use crate::utils::{get_contract_address_from_reader, parse_block_id}; + +pub async fn call( + world_reader: WorldContractReader

, + contract: String, + entrypoint: String, + calldata: Vec, + block_id: Option, +) -> Result<()> { + let contract_address = get_contract_address_from_reader(&world_reader, contract).await?; + let block_id = if let Some(block_id) = block_id { + parse_block_id(block_id)? + } else { + BlockId::Tag(BlockTag::Pending) + }; + + let output = world_reader + .provider() + .call( + FunctionCall { + contract_address, + entry_point_selector: get_selector_from_name(&entrypoint)?, + calldata, + }, + block_id, + ) + .await + .with_context(|| format!("Failed to call {entrypoint}"))?; + + println!("[ {} ]", output.iter().map(|o| format!("0x{:x}", o)).collect::>().join(" ")); + + Ok(()) +} diff --git a/crates/sozo/ops/src/lib.rs b/crates/sozo/ops/src/lib.rs index 2ba2bf238e..3d1b69ce11 100644 --- a/crates/sozo/ops/src/lib.rs +++ b/crates/sozo/ops/src/lib.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod call; pub mod events; pub mod execute; pub mod migration; diff --git a/crates/sozo/ops/src/tests/call.rs b/crates/sozo/ops/src/tests/call.rs new file mode 100644 index 0000000000..331215c03f --- /dev/null +++ b/crates/sozo/ops/src/tests/call.rs @@ -0,0 +1,147 @@ +use dojo_test_utils::sequencer::{ + get_default_test_starknet_config, SequencerConfig, TestSequencer, +}; +use dojo_world::contracts::WorldContractReader; +use starknet::accounts::SingleOwnerAccount; +use starknet::providers::jsonrpc::HttpTransport; +use starknet::providers::JsonRpcClient; +use starknet::signers::LocalWallet; +use starknet_crypto::FieldElement; + +use super::setup; +use crate::{call, utils}; + +const CONTRACT_NAME: &str = "dojo_examples::actions::actions"; +const ENTRYPOINT: &str = "tile_terrain"; + +#[tokio::test] +async fn call_with_bad_address() { + let sequencer = + TestSequencer::start(SequencerConfig::default(), get_default_test_starknet_config()).await; + + let world = setup::setup(&sequencer).await.unwrap(); + let provider = sequencer.provider(); + let world_reader = WorldContractReader::new(world.address, provider); + + assert!( + call::call( + world_reader, + "0xBadCoffeeBadCode".to_string(), + ENTRYPOINT.to_string(), + vec![FieldElement::ZERO, FieldElement::ZERO], + None + ) + .await + .is_err() + ); +} + +#[tokio::test] +async fn call_with_bad_name() { + let sequencer = + TestSequencer::start(SequencerConfig::default(), get_default_test_starknet_config()).await; + + let world = setup::setup(&sequencer).await.unwrap(); + let provider = sequencer.provider(); + let world_reader = WorldContractReader::new(world.address, provider); + + assert!( + call::call( + world_reader, + "BadName".to_string(), + ENTRYPOINT.to_string(), + vec![FieldElement::ZERO, FieldElement::ZERO], + None + ) + .await + .is_err() + ); +} + +#[tokio::test] +async fn call_with_bad_entrypoint() { + let sequencer = + TestSequencer::start(SequencerConfig::default(), get_default_test_starknet_config()).await; + + let world = setup::setup(&sequencer).await.unwrap(); + let provider = sequencer.provider(); + let world_reader = WorldContractReader::new(world.address, provider); + + assert!( + call::call( + world_reader, + CONTRACT_NAME.to_string(), + "BadEntryPoint".to_string(), + vec![FieldElement::ZERO, FieldElement::ZERO], + None + ) + .await + .is_err() + ); +} + +#[tokio::test] +async fn call_with_bad_calldata() { + let sequencer = + TestSequencer::start(SequencerConfig::default(), get_default_test_starknet_config()).await; + + let world = setup::setup(&sequencer).await.unwrap(); + let provider = sequencer.provider(); + let world_reader = WorldContractReader::new(world.address, provider); + + assert!( + call::call(world_reader, CONTRACT_NAME.to_string(), ENTRYPOINT.to_string(), vec![], None) + .await + .is_err() + ); +} + +#[tokio::test] +async fn call_with_contract_name() { + let sequencer = + TestSequencer::start(SequencerConfig::default(), get_default_test_starknet_config()).await; + + let world = setup::setup(&sequencer).await.unwrap(); + let provider = sequencer.provider(); + let world_reader = WorldContractReader::new(world.address, provider); + + assert!( + call::call( + world_reader, + CONTRACT_NAME.to_string(), + ENTRYPOINT.to_string(), + vec![FieldElement::ZERO, FieldElement::ZERO], + None, + ) + .await + .is_ok() + ); +} + +#[tokio::test] +async fn call_with_contract_address() { + let sequencer = + TestSequencer::start(SequencerConfig::default(), get_default_test_starknet_config()).await; + + let world = setup::setup(&sequencer).await.unwrap(); + let provider = sequencer.provider(); + let world_reader = WorldContractReader::new(world.address, provider); + + let contract_address = utils::get_contract_address::< + SingleOwnerAccount, LocalWallet>, + >(&world, CONTRACT_NAME.to_string()) + .await + .unwrap(); + + assert!( + call::call( + world_reader, + format!("{:#x}", contract_address), + ENTRYPOINT.to_string(), + vec![FieldElement::ZERO, FieldElement::ZERO], + None, + ) + .await + .is_ok() + ); +} diff --git a/crates/sozo/ops/src/tests/mod.rs b/crates/sozo/ops/src/tests/mod.rs index 1cc5eab8d1..25bdba5697 100644 --- a/crates/sozo/ops/src/tests/mod.rs +++ b/crates/sozo/ops/src/tests/mod.rs @@ -1,3 +1,4 @@ pub mod auth; +pub mod call; pub mod setup; pub mod utils; diff --git a/crates/sozo/ops/src/tests/utils.rs b/crates/sozo/ops/src/tests/utils.rs index 92d22fb0b6..71859f079e 100644 --- a/crates/sozo/ops/src/tests/utils.rs +++ b/crates/sozo/ops/src/tests/utils.rs @@ -2,7 +2,9 @@ use dojo_test_utils::sequencer::{ get_default_test_starknet_config, SequencerConfig, TestSequencer, }; use dojo_world::contracts::world::WorldContract; -use starknet::core::types::FieldElement; +use dojo_world::contracts::WorldContractReader; +use starknet::accounts::ConnectedAccount; +use starknet::core::types::{BlockId, BlockTag, FieldElement}; use super::setup; use crate::utils; @@ -34,3 +36,70 @@ async fn get_contract_address_from_string() { assert_eq!(contract_address, FieldElement::from_hex_be("0x1234").unwrap()); } + +#[tokio::test(flavor = "multi_thread")] +async fn get_contract_address_from_world_with_world_reader() { + let sequencer = + TestSequencer::start(SequencerConfig::default(), get_default_test_starknet_config()).await; + + let world = setup::setup(&sequencer).await.unwrap(); + let account = sequencer.account(); + let provider = account.provider(); + let world_reader = WorldContractReader::new(world.address, provider); + + let contract_address = + utils::get_contract_address_from_reader(&world_reader, ACTION_CONTRACT_NAME.to_string()) + .await + .unwrap(); + + assert!(contract_address != FieldElement::ZERO); +} + +#[tokio::test(flavor = "multi_thread")] +async fn get_contract_address_from_string_with_world_reader() { + let sequencer = + TestSequencer::start(SequencerConfig::default(), get_default_test_starknet_config()).await; + + let provider = sequencer.provider(); + let world_reader = WorldContractReader::new(FieldElement::ZERO, provider); + + let contract_address = + utils::get_contract_address_from_reader(&world_reader, "0x1234".to_string()).await.unwrap(); + + assert_eq!(contract_address, FieldElement::from_hex_be("0x1234").unwrap()); +} + +#[test] +fn parse_block_id_bad_hash() { + assert!(utils::parse_block_id("0xBadHash".to_string()).is_err()); +} + +#[test] +fn parse_block_id_bad_string() { + assert!(utils::parse_block_id("BadString".to_string()).is_err()); +} + +#[test] +fn parse_block_id_hash() { + assert!( + utils::parse_block_id("0x1234".to_string()).unwrap() + == BlockId::Hash(FieldElement::from_hex_be("0x1234").unwrap()) + ); +} + +#[test] +fn parse_block_id_pending() { + assert!( + utils::parse_block_id("pending".to_string()).unwrap() == BlockId::Tag(BlockTag::Pending) + ); +} + +#[test] +fn parse_block_id_latest() { + assert!(utils::parse_block_id("latest".to_string()).unwrap() == BlockId::Tag(BlockTag::Latest)); +} + +#[test] +fn parse_block_id_number() { + assert!(utils::parse_block_id("42".to_string()).unwrap() == BlockId::Number(42)); +} diff --git a/crates/sozo/ops/src/utils.rs b/crates/sozo/ops/src/utils.rs index 673a9a9a29..00735e59a4 100644 --- a/crates/sozo/ops/src/utils.rs +++ b/crates/sozo/ops/src/utils.rs @@ -1,9 +1,11 @@ -use anyhow::Result; -use dojo_world::contracts::world::WorldContract; +use anyhow::{anyhow, Result}; +use dojo_world::contracts::world::{WorldContract, WorldContractReader}; use dojo_world::migration::strategy::generate_salt; use dojo_world::utils::{execution_status_from_maybe_pending_receipt, TransactionWaiter}; use starknet::accounts::ConnectedAccount; -use starknet::core::types::{ExecutionResult, FieldElement, InvokeTransactionResult}; +use starknet::core::types::{ + BlockId, BlockTag, ExecutionResult, FieldElement, InvokeTransactionResult, +}; use starknet::providers::Provider; /// Retrieves a contract address from it's name @@ -35,6 +37,35 @@ pub async fn get_contract_address( } } +/// Retrieves a contract address from its name +/// using a world contract reader, or parses a hex string into +/// a [`FieldElement`]. +/// +/// # Arguments +/// +/// * `world_reader` - The world contract reader. +/// * `name_or_address` - A string with a contract name or a hexadecimal address. +/// +/// # Returns +/// +/// A [`FieldElement`] with the address of the contract on success. +pub async fn get_contract_address_from_reader( + world_reader: &WorldContractReader

, + name_or_address: String, +) -> Result { + if name_or_address.starts_with("0x") { + FieldElement::from_hex_be(&name_or_address).map_err(anyhow::Error::from) + } else { + let contract_class_hash = world_reader.base().call().await?; + Ok(starknet::core::utils::get_contract_address( + generate_salt(&name_or_address), + contract_class_hash.into(), + &[], + world_reader.address, + )) + } +} + /// Handles a transaction result configuring a /// [`TransactionWaiter`] if required. /// @@ -76,3 +107,30 @@ where Ok(()) } + +/// Parses a string into a [`BlockId`]. +/// +/// # Arguments +/// +/// * `block_str` - a string representing a block ID. It could be a +/// block hash starting with 0x, a block number, 'pending' or 'latest'. +/// +/// # Returns +/// +/// The parsed [`BlockId`] on success. +pub fn parse_block_id(block_str: String) -> Result { + if block_str.starts_with("0x") { + let hash = FieldElement::from_hex_be(&block_str) + .map_err(|_| anyhow!("Unable to parse block hash: {}", block_str))?; + Ok(BlockId::Hash(hash)) + } else if block_str.eq("pending") { + Ok(BlockId::Tag(BlockTag::Pending)) + } else if block_str.eq("latest") { + Ok(BlockId::Tag(BlockTag::Latest)) + } else { + match block_str.parse::() { + Ok(n) => Ok(BlockId::Number(n)), + Err(_) => Err(anyhow!("Unable to parse block ID: {}", block_str)), + } + } +}