From c4bf27e7a260b9113d006150bbd621a936c6746d Mon Sep 17 00:00:00 2001 From: glihm Date: Thu, 7 Nov 2024 21:44:08 -0600 Subject: [PATCH 1/2] feat: add back sozo auth + new sozo auth list --- Cargo.lock | 1 + bin/sozo/Cargo.toml | 1 + bin/sozo/src/commands/auth.rs | 750 +++++++++++-------- bin/sozo/src/commands/execute.rs | 25 +- bin/sozo/src/commands/mod.rs | 6 + bin/sozo/src/commands/options/transaction.rs | 2 +- bin/sozo/src/utils.rs | 24 + crates/dojo/types/src/naming.rs | 24 + crates/dojo/world/src/diff/resource.rs | 4 + crates/sozo/ops/src/auth.rs | 6 - crates/sozo/ops/src/migration_ui.rs | 9 + 11 files changed, 523 insertions(+), 329 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2c5537c005..04b97ff854 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13233,6 +13233,7 @@ version = "1.0.0-rc.1" dependencies = [ "anyhow", "async-trait", + "cainome 0.4.6", "cairo-lang-compiler", "cairo-lang-filesystem", "cairo-lang-project", diff --git a/bin/sozo/Cargo.toml b/bin/sozo/Cargo.toml index 53873c14d1..d7dd72b1e6 100644 --- a/bin/sozo/Cargo.toml +++ b/bin/sozo/Cargo.toml @@ -10,6 +10,7 @@ slot = { workspace = true, optional = true } anyhow.workspace = true async-trait.workspace = true +cainome.workspace = true cairo-lang-compiler.workspace = true cairo-lang-filesystem.workspace = true cairo-lang-project.workspace = true diff --git a/bin/sozo/src/commands/auth.rs b/bin/sozo/src/commands/auth.rs index 0c98c0de4a..2f3fef939e 100644 --- a/bin/sozo/src/commands/auth.rs +++ b/bin/sozo/src/commands/auth.rs @@ -1,15 +1,27 @@ -use anyhow::Result; +use std::collections::HashMap; +use std::str::FromStr; + +use anyhow::{anyhow, Result}; +use cainome::cairo_serde::ContractAddress; use clap::{Args, Subcommand}; -use dojo_world::config::Environment; -use dojo_world::metadata::get_default_namespace_from_ws; -use scarb::core::Config; -use scarb_ui::Ui; -use sozo_ops::auth; -#[cfg(feature = "walnut")] +use colored::Colorize; +use dojo_utils::{Invoker, TxnConfig}; +use dojo_world::config::{calldata_decoder, ProfileConfig}; +use dojo_world::contracts::{ContractInfo, WorldContract}; +use dojo_world::diff::DiffPermissions; +use scarb::core::{Config, Workspace}; +use sozo_ops::migration_ui::MigrationUi; +use sozo_ops::resource_descriptor::ResourceDescriptor; +use sozo_scarbext::WorkspaceExt; use sozo_walnut::WalnutDebugger; +use starknet::accounts::ConnectedAccount; +use starknet::core::types::{Call, Felt}; +use starknet::core::utils as snutils; +use starknet::providers::jsonrpc::HttpTransport; +use starknet::providers::{JsonRpcClient, Provider}; use tracing::trace; -use super::options::account::AccountOptions; +use super::options::account::{AccountOptions, SozoAccount}; use super::options::starknet::StarknetOptions; use super::options::transaction::TransactionOptions; use super::options::world::WorldOptions; @@ -29,16 +41,7 @@ pub enum AuthCommand { kind: AuthKind, #[command(flatten)] - world: WorldOptions, - - #[command(flatten)] - starknet: StarknetOptions, - - #[command(flatten)] - account: AccountOptions, - - #[command(flatten)] - transaction: TransactionOptions, + common: CommonAuthOptions, }, #[command(about = "Revoke an auth role.")] Revoke { @@ -46,356 +49,493 @@ pub enum AuthCommand { kind: AuthKind, #[command(flatten)] - world: WorldOptions, + common: CommonAuthOptions, + }, + #[command(about = "List the permissions.")] + List { + #[arg(help = "The tag of the resource to inspect. If not provided, a world summary will \ + be displayed.")] + resource: Option, + + #[arg( + long, + help = "Print the address of the grantees, by default only the tag is printed." + )] + show_address: bool, #[command(flatten)] starknet: StarknetOptions, #[command(flatten)] - account: AccountOptions, - - #[command(flatten)] - transaction: TransactionOptions, + world: WorldOptions, }, } -impl AuthArgs { - pub fn run(self, config: &Config) -> Result<()> { - trace!(args = ?self); +#[derive(Debug, Args)] +pub struct CommonAuthOptions { + #[command(flatten)] + world: WorldOptions, - let env_metadata = utils::load_metadata_from_config(config)?; - trace!(metadata=?env_metadata, "Loaded environment."); + #[command(flatten)] + starknet: StarknetOptions, - let ws = scarb::ops::read_workspace(config.manifest_path(), config)?; - let default_namespace = get_default_namespace_from_ws(&ws)?; - - match self.command { - AuthCommand::Grant { kind, world, starknet, account, transaction } => { - config.tokio_handle().block_on(grant( - &config.ui(), - world, - account, - starknet, - env_metadata, - kind, - transaction, - config, - &default_namespace, - )) - } - AuthCommand::Revoke { kind, world, starknet, account, transaction } => { - config.tokio_handle().block_on(revoke( - &config.ui(), - world, - account, - starknet, - env_metadata, - kind, - transaction, - config, - &default_namespace, - )) - } - } - } + #[command(flatten)] + account: AccountOptions, + + #[command(flatten)] + transaction: TransactionOptions, } #[derive(Debug, Subcommand)] pub enum AuthKind { - #[command(about = "Grant a contract permission to write to a resource (contract, model or \ - namespace).")] + #[command(about = "Grant to a contract the permission to write to a resource.")] Writer { #[arg(num_args = 1..)] #[arg(required = true)] - #[arg(value_name = "resource,contract_tag_or_address")] + #[arg(value_name = "resource_tag,contract_tag_or_address")] #[arg(help = "A list of resource/contract couples to grant write access to. -Comma separated values to indicate resource identifier and contract tag or address. -A resource identifier must use the following format: \ - :.\n +Comma separated values to indicate resource identifier and contract tag or address.\n Some examples: - model:dojo_examples-Moves,0x1234 - m:Moves,0x1234 - ns:dojo_examples,actions + ns-Moves,0x1234 + ns,ns-actions ")] - models_contracts: Vec, + pairs: Vec, }, - #[command(about = "Grant ownership of a resource (contract, model or namespace).")] + + #[command(about = "Grant to a contract the ownership of a resource.")] Owner { #[arg(num_args = 1..)] #[arg(required = true)] - #[arg(value_name = "resource,owner_address")] + #[arg(value_name = "resource_tag,contract_tag_or_address")] #[arg(help = "A list of resources and owners to grant ownership to. -Comma separated values to indicate resource identifier and owner address. -A resource identifier must use the following format: \ - :.\n +Comma separated values to indicate resource identifier and owner address.\n Some examples: - model:dojo_examples-Moves,0x1234 - m:Moves,0x1234 - ns:dojo_examples,0xbeef + ns-Moves,ns-actions + ns,0xbeef ")] - owners_resources: Vec, + pairs: Vec, }, } -#[allow(clippy::too_many_arguments)] -pub async fn grant( - ui: &Ui, - world: WorldOptions, - account: AccountOptions, - starknet: StarknetOptions, - env_metadata: Option, - kind: AuthKind, - transaction: TransactionOptions, - config: &Config, - default_namespace: &str, -) -> Result<()> { - trace!(?kind, ?world, ?starknet, ?account, ?transaction, "Executing Grant command."); - let world = - utils::world_from_env_metadata(world, account, &starknet, &env_metadata, config).await?; - - #[cfg(feature = "walnut")] - let walnut_debugger = - WalnutDebugger::new_from_flag(transaction.walnut, starknet.url(env_metadata.as_ref())?); +impl AuthArgs { + pub fn run(self, config: &Config) -> Result<()> { + trace!(args = ?self); - match kind { - AuthKind::Writer { models_contracts } => { - trace!( - contracts=?models_contracts, - "Granting Writer permissions." - ); - auth::grant_writer( - ui, - &world, - &models_contracts, - &transaction.into(), - default_namespace, - #[cfg(feature = "walnut")] - &walnut_debugger, - ) - .await - } - AuthKind::Owner { owners_resources } => { - trace!( - resources=?owners_resources, - "Granting Owner permissions." - ); - auth::grant_owner( - ui, - &world, - &owners_resources, - &transaction.into(), - default_namespace, - #[cfg(feature = "walnut")] - &walnut_debugger, - ) - .await - } + let ws = scarb::ops::read_workspace(config.manifest_path(), config)?; + let profile_config = ws.load_profile_config()?; + + config.tokio_handle().block_on(async { + match self.command { + AuthCommand::Grant { kind, common, .. } => { + let contracts = utils::contracts_from_manifest_or_diff( + common.account.clone(), + common.starknet.clone(), + common.world.clone(), + &ws, + false, + ) + .await?; + + let do_grant = true; + + match kind { + AuthKind::Writer { pairs } => { + let is_writer = true; + update_permissions( + &contracts, + &common, + &profile_config, + pairs, + is_writer, + do_grant, + ) + .await?; + } + AuthKind::Owner { pairs } => { + let is_writer = false; + update_permissions( + &contracts, + &common, + &profile_config, + pairs, + is_writer, + do_grant, + ) + .await?; + } + } + } + AuthCommand::Revoke { kind, common, .. } => { + let contracts = utils::contracts_from_manifest_or_diff( + common.account.clone(), + common.starknet.clone(), + common.world.clone(), + &ws, + false, + ) + .await?; + + let do_grant = false; + + match kind { + AuthKind::Writer { pairs } => { + let is_writer = true; + update_permissions( + &contracts, + &common, + &profile_config, + pairs, + is_writer, + do_grant, + ) + .await?; + } + AuthKind::Owner { pairs } => { + let is_writer = false; + update_permissions( + &contracts, + &common, + &profile_config, + pairs, + is_writer, + do_grant, + ) + .await?; + } + } + } + AuthCommand::List { resource, show_address, starknet, world } => { + list_permissions(resource, show_address, starknet, world, &ws).await?; + } + }; + + Ok(()) + }) } } -#[allow(clippy::too_many_arguments)] -pub async fn revoke( - ui: &Ui, - world: WorldOptions, - account: AccountOptions, +/// Lists the permissions of a resource. +async fn list_permissions( + resource: Option, + show_address: bool, starknet: StarknetOptions, - env_metadata: Option, - kind: AuthKind, - transaction: TransactionOptions, - config: &Config, - default_namespace: &str, + world: WorldOptions, + ws: &Workspace<'_>, ) -> Result<()> { - trace!(?kind, ?world, ?starknet, ?account, ?transaction, "Executing Revoke command."); - let world = - utils::world_from_env_metadata(world, account, &starknet, &env_metadata, config).await?; + let mut migration_ui = MigrationUi::new_with_frames( + "Gathering permissions from the world...", + vec!["🌍", "🔍", "📜"], + ); - #[cfg(feature = "walnut")] - let walnut_debugger = - WalnutDebugger::new_from_flag(transaction.walnut, starknet.url(env_metadata.as_ref())?); + let (world_diff, _, _) = utils::get_world_diff_and_provider(starknet, world, ws).await?; - match kind { - AuthKind::Writer { models_contracts } => { - trace!( - contracts=?models_contracts, - "Revoking Writer permissions." - ); - auth::revoke_writer( - ui, - &world, - &models_contracts, - &transaction.into(), - default_namespace, - #[cfg(feature = "walnut")] - &walnut_debugger, - ) - .await - } - AuthKind::Owner { owners_resources } => { - trace!( - resources=?owners_resources, - "Revoking Owner permissions." - ); - auth::revoke_owner( - ui, - &world, - &owners_resources, - &transaction.into(), - default_namespace, - #[cfg(feature = "walnut")] - &walnut_debugger, - ) - .await + // Sort resources by tag for deterministic output. + let mut resources = world_diff.resources.values().collect::>(); + resources.sort_by_key(|r| r.tag().clone()); + + migration_ui.stop(); + + if let Some(resource) = resource { + let selector = dojo_types::naming::compute_selector_from_tag_or_name(&resource); + resources.retain(|r| r.dojo_selector() == selector); + + if resources.is_empty() { + anyhow::bail!("Resource {} not found.", resource.bright_blue()); } } -} -#[cfg(test)] -mod tests { - use std::str::FromStr; + if resources.is_empty() { + println!("No resource found."); + return Ok(()); + } - use starknet::core::types::Felt; + let mut has_printed_at_least_one = false; - use super::*; + for resource in resources.iter() { + let selector = resource.dojo_selector(); + let writers = world_diff.get_writers(selector); + let owners = world_diff.get_owners(selector); - #[test] - fn test_resource_type_from_str() { - let inputs = [ - ( - "contract:name:contract_name", - auth::ResourceType::Contract("name:contract_name".to_string()), - ), - ("c:0x1234", auth::ResourceType::Contract("0x1234".to_string())), - ("model:name:model_name", auth::ResourceType::Model("name:model_name".to_string())), - ("m:name:model_name", auth::ResourceType::Model("name:model_name".to_string())), - ( - "namespace:namespace_name", - auth::ResourceType::Namespace("namespace_name".to_string()), - ), - ("ns:namespace_name", auth::ResourceType::Namespace("namespace_name".to_string())), - ]; - - for (input, expected) in inputs { - let res = auth::ResourceType::from_str(input); - assert!(res.is_ok(), "Unable to parse input '{input}'"); - - let resource = res.unwrap(); - assert!( - resource == expected, - "Wrong resource type: expected: {:#?} got: {:#?}", - expected, - resource - ); + if writers.is_empty() && owners.is_empty() { + continue; } - } - #[test] - fn test_resource_type_from_str_bad_resource_identifier() { - let input = "other:model_name"; - let res = auth::ResourceType::from_str(input); - assert!(res.is_err(), "Bad identifier: This resource should not be accepted: '{input}'"); + has_printed_at_least_one = true; + + println!("{}", resource.tag().bright_blue()); + + if !writers.is_empty() { + println!("writers: "); + print_diff_permissions(&writers, show_address); + } + + if !owners.is_empty() { + println!("owners: "); + print_diff_permissions(&owners, show_address); + } + + println!(); } - #[test] - fn test_resource_type_from_str_bad_resource_format() { - let input = "model_name"; - let res = auth::ResourceType::from_str(input); - assert!(res.is_err(), "Bad format: This resource should not be accepted: '{input}'"); + if resources.len() == 1 && !has_printed_at_least_one { + println!("No permission found."); } - #[test] - fn test_resource_writer_from_str() { - let inputs = [ - ( - "model:name:model_name,name:contract_name", - auth::ResourceWriter { - resource: auth::ResourceType::Model("name:model_name".to_string()), - tag_or_address: "name:contract_name".to_string(), - }, - ), - ( - "ns:namespace_name,0x1234", - auth::ResourceWriter { - resource: auth::ResourceType::Namespace("namespace_name".to_string()), - tag_or_address: "0x1234".to_string(), - }, - ), - ]; - - for (input, expected) in inputs { - let res = auth::ResourceWriter::from_str(input); - assert!(res.is_ok(), "Unable to parse input '{input}'"); - - let writer = res.unwrap(); - assert!( - writer == expected, - "Wrong resource writer: expected: {:#?} got: {:#?}", - expected, - writer - ); - } + Ok(()) +} + +/// Pretty prints the permissions of a resource. +fn print_diff_permissions(diff: &DiffPermissions, show_address: bool) { + if !diff.only_local().is_empty() { + println!( + " local: {}", + diff.only_local() + .iter() + .map(|w| format!( + "{} {}", + w.tag.clone().unwrap_or("external".to_string()), + if show_address { + format!("({:#066x})", w.address).bright_black() + } else { + "".to_string().bright_black() + } + )) + .collect::>() + .join(", ") + ); } - #[test] - fn test_resource_writer_from_str_bad_format() { - let input = "model_name"; - let res = auth::ResourceWriter::from_str(input); - assert!(res.is_err(), "Bad format: This resource writer should not be accepted: '{input}'"); + if !diff.only_remote().is_empty() { + println!( + " remote: {}", + diff.only_remote() + .iter() + .map(|w| format!( + "{} {}", + w.tag.clone().unwrap_or("external".to_string()), + if show_address { + format!("({:#066x})", w.address).bright_black() + } else { + "".to_string().bright_black() + } + )) + .collect::>() + .join(", ") + ); } - #[test] - fn test_resource_writer_from_str_bad_owner_address() { - let input = "model:model_name:bad_address"; - let res = auth::ResourceWriter::from_str(input); - assert!( - res.is_err(), - "Bad address: This resource writer should not be accepted: '{input}'" + if !diff.synced().is_empty() { + println!( + " synced: {}", + diff.synced() + .iter() + .map(|w| format!( + "{} {}", + w.tag.clone().unwrap_or("external".to_string()), + if show_address { + format!("({:#066x})", w.address).bright_black() + } else { + "".to_string().bright_black() + } + )) + .collect::>() + .join(", ") ); } +} - #[test] - fn test_resource_owner_from_str() { - let inputs = [ - ( - "model:name:model_name,0x1234", - auth::ResourceOwner { - resource: auth::ResourceType::Model("name:model_name".to_string()), - owner: Felt::from_hex("0x1234").unwrap(), - }, - ), - ( - "ns:namespace_name,0x1111", - auth::ResourceOwner { - resource: auth::ResourceType::Namespace("namespace_name".to_string()), - owner: Felt::from_hex("0x1111").unwrap(), - }, - ), - ]; - - for (input, expected) in inputs { - let res = auth::ResourceOwner::from_str(input); - assert!(res.is_ok(), "Unable to parse input '{input}'"); - - let owner = res.unwrap(); - assert!( - owner == expected, - "Wrong resource owner: expected: {:#?} got: {:#?}", - expected, - owner +/// Updates the permissions of a resource for a contract. +async fn update_permissions( + contracts: &HashMap, + options: &CommonAuthOptions, + profile_config: &ProfileConfig, + pairs: Vec, + is_writer: bool, + do_grant: bool, +) -> Result<()> { + let selectors_addresses = pairs + .iter() + .map(|p| p.to_selector_and_address(&contracts)) + .collect::>>()?; + + let world = get_world_contract(contracts, options, profile_config).await?; + + let mut invoker = Invoker::new(&world.account, options.transaction.clone().try_into()?); + for (selector, address) in selectors_addresses { + let call = if is_writer { + if do_grant { + trace!( + selector = format!("{:#066x}", selector), + address = format!("{:#066x}", address), + "Grant writer call." + ); + world.grant_writer_getcall(&selector, &ContractAddress(address)) + } else { + trace!( + selector = format!("{:#066x}", selector), + address = format!("{:#066x}", address), + "Revoke writer call." + ); + world.revoke_writer_getcall(&selector, &ContractAddress(address)) + } + } else if do_grant { + trace!( + selector = format!("{:#066x}", selector), + address = format!("{:#066x}", address), + "Grant owner call." ); - } + world.grant_owner_getcall(&selector, &ContractAddress(address)) + } else { + trace!( + selector = format!("{:#066x}", selector), + address = format!("{:#066x}", address), + "Revoke owner call." + ); + world.revoke_owner_getcall(&selector, &ContractAddress(address)) + }; + + invoker.add_call(call); + } + + let res = invoker.multicall().await?; + println!("{}", res); + + Ok(()) +} + +/// Gets the world contract from the contracts map and initializes a world contract instance +/// from the environment. +async fn get_world_contract( + contracts: &HashMap, + options: &CommonAuthOptions, + profile_config: &ProfileConfig, +) -> Result>>> { + let env = profile_config.env.as_ref(); + let (provider, _) = options.starknet.provider(env)?; + let account = options.account.account(provider, env, &options.starknet, contracts).await?; + let world_address = contracts + .get("world") + .ok_or_else(|| anyhow!("World contract not found in the manifest."))? + .address; + + let world = WorldContract::new(world_address, account); + + Ok(world) +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PermissionPair { + pub resource_tag: String, + pub grantee_tag_or_address: String, +} + +impl PermissionPair { + /// Returns the selector and the contract address from the permission pair. + /// + /// If the grantee tag is not found in the contracts (from the manifest or from the diff), an + /// error is returned as we're expecting the resource to be resolved locally. + pub fn to_selector_and_address( + &self, + contracts: &HashMap, + ) -> Result<(Felt, Felt)> { + let selector = dojo_types::naming::compute_selector_from_tag_or_name(&self.resource_tag); + + let contract_address = if self.grantee_tag_or_address.starts_with("0x") { + Felt::from_str(&self.grantee_tag_or_address) + .map_err(|_| anyhow!("Invalid contract address: {}", self.grantee_tag_or_address))? + } else { + contracts + .get(&self.grantee_tag_or_address) + .ok_or_else(|| { + anyhow!("Contract {} not found in the manifest.", self.grantee_tag_or_address) + })? + .address + }; + + Ok((selector, contract_address)) + } +} + +impl FromStr for PermissionPair { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let parts: Vec<&str> = s.split(',').collect(); + + let (resource_tag, grantee_tag_or_address) = match parts.as_slice() { + [resource_tag, grantee_tag_or_address] => { + (resource_tag.to_string(), grantee_tag_or_address.to_string()) + } + _ => anyhow::bail!( + "Resource and contract are expected to be comma separated: `sozo auth grant \ + writer resource_tag,contract_tag_or_address`" + ), + }; + + Ok(PermissionPair { resource_tag, grantee_tag_or_address }) } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; #[test] - fn test_resource_owner_from_str_bad_format() { - let input = "model_name"; - let res = auth::ResourceOwner::from_str(input); - assert!(res.is_err(), "Bad format: This resource owner should not be accepted: '{input}'"); + fn test_permission_pair_from_str() { + let pair = PermissionPair::from_str("moves,actions").unwrap(); + assert_eq!(pair.resource_tag, "moves"); + assert_eq!(pair.grantee_tag_or_address, "actions"); + + let pair = PermissionPair::from_str("moves,0x123").unwrap(); + assert_eq!(pair.resource_tag, "moves"); + assert_eq!(pair.grantee_tag_or_address, "0x123"); + + assert!(PermissionPair::from_str("moves").is_err()); + assert!(PermissionPair::from_str("moves,actions,extra").is_err()); } #[test] - fn test_resource_owner_from_str_bad_owner_address() { - let input = "model:model_name:bad_address"; - let res = auth::ResourceOwner::from_str(input); - assert!(res.is_err(), "Bad address: This resource owner should not be accepted: '{input}'"); + fn test_permission_pair_to_selector_and_address() { + let mut contracts = HashMap::new(); + contracts.insert( + "actions".to_string(), + ContractInfo { + tag: "actions".to_string(), + address: Felt::from_str("0x456").unwrap(), + entrypoints: vec![], + }, + ); + + let pair = PermissionPair { + resource_tag: "moves".to_string(), + grantee_tag_or_address: "actions".to_string(), + }; + + let (selector, address) = pair.to_selector_and_address(&contracts).unwrap(); + + assert_eq!(selector, dojo_types::naming::compute_selector_from_tag_or_name("moves")); + assert_eq!(address, Felt::from_str("0x456").unwrap()); + + let pair = PermissionPair { + resource_tag: "moves".to_string(), + grantee_tag_or_address: "0x123".to_string(), + }; + let (selector, address) = pair.to_selector_and_address(&contracts).unwrap(); + assert_eq!(selector, dojo_types::naming::compute_selector_from_tag_or_name("moves")); + assert_eq!(address, Felt::from_str("0x123").unwrap()); + + let pair = PermissionPair { + resource_tag: "moves".to_string(), + grantee_tag_or_address: "nonexistent".to_string(), + }; + assert!(pair.to_selector_and_address(&contracts).is_err()); + + let pair = PermissionPair { + resource_tag: "moves".to_string(), + grantee_tag_or_address: "0xinvalid".to_string(), + }; + assert!(pair.to_selector_and_address(&contracts).is_err()); } } diff --git a/bin/sozo/src/commands/execute.rs b/bin/sozo/src/commands/execute.rs index 512f7213e8..57d62ea18d 100644 --- a/bin/sozo/src/commands/execute.rs +++ b/bin/sozo/src/commands/execute.rs @@ -78,26 +78,17 @@ impl ExecuteArgs { let txn_config: TxnConfig = self.transaction.try_into()?; config.tokio_handle().block_on(async { - let local_manifest = ws.read_manifest_profile()?; - let use_diff = self.diff || local_manifest.is_none(); - let (contract_address, contracts) = match &descriptor { ResourceDescriptor::Address(address) => (Some(*address), Default::default()), ResourceDescriptor::Tag(tag) => { - let contracts: HashMap = if use_diff { - let (world_diff, _, _) = utils::get_world_diff_and_account( - self.account.clone(), - self.starknet.clone(), - self.world, - &ws, - &mut None, - ) - .await?; - - (&world_diff).into() - } else { - (&local_manifest.unwrap()).into() - }; + let contracts = utils::contracts_from_manifest_or_diff( + self.account.clone(), + self.starknet.clone(), + self.world, + &ws, + self.diff, + ) + .await?; (contracts.get(tag).map(|c| c.address), contracts) } diff --git a/bin/sozo/src/commands/mod.rs b/bin/sozo/src/commands/mod.rs index 81d06fc040..f3b685796d 100644 --- a/bin/sozo/src/commands/mod.rs +++ b/bin/sozo/src/commands/mod.rs @@ -1,11 +1,13 @@ use core::fmt; use anyhow::Result; +use auth::AuthArgs; use clap::Subcommand; use events::EventsArgs; use scarb::core::{Config, Package, Workspace}; use tracing::info_span; +pub(crate) mod auth; pub(crate) mod build; pub(crate) mod call; pub(crate) mod clean; @@ -32,6 +34,8 @@ use test::TestArgs; #[derive(Debug, Subcommand)] pub enum Commands { + #[command(about = "Grant or revoke a contract permission to write to a resource")] + Auth(Box), #[command(about = "Build the world, generating the necessary artifacts for deployment")] Build(BuildArgs), #[command(about = "Run a migration, declaring and deploying contracts as necessary to update \ @@ -60,6 +64,7 @@ pub enum Commands { impl fmt::Display for Commands { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Commands::Auth(_) => write!(f, "Auth"), Commands::Build(_) => write!(f, "Build"), Commands::Clean(_) => write!(f, "Clean"), Commands::Execute(_) => write!(f, "Execute"), @@ -84,6 +89,7 @@ pub fn run(command: Commands, config: &Config) -> Result<()> { // useful to write tests for each command. match command { + Commands::Auth(args) => args.run(config), Commands::Build(args) => args.run(config), Commands::Migrate(args) => args.run(config), Commands::Execute(args) => args.run(config), diff --git a/bin/sozo/src/commands/options/transaction.rs b/bin/sozo/src/commands/options/transaction.rs index 6e956902c3..88786e9460 100644 --- a/bin/sozo/src/commands/options/transaction.rs +++ b/bin/sozo/src/commands/options/transaction.rs @@ -6,7 +6,7 @@ use clap::{Args, ValueEnum}; use dojo_utils::{EthFeeConfig, FeeConfig, StrkFeeConfig, TxnAction, TxnConfig}; use starknet::core::types::Felt; -#[derive(Debug, Args, Default)] +#[derive(Debug, Clone, Args, Default)] #[command(next_help_heading = "Transaction options")] pub struct TransactionOptions { #[arg(long)] diff --git a/bin/sozo/src/utils.rs b/bin/sozo/src/utils.rs index 3222051d90..16e72456d4 100644 --- a/bin/sozo/src/utils.rs +++ b/bin/sozo/src/utils.rs @@ -1,9 +1,11 @@ +use std::collections::HashMap; use std::str::FromStr; use anyhow::{anyhow, Context, Result}; use camino::Utf8PathBuf; use colored::*; use dojo_world::config::ProfileConfig; +use dojo_world::contracts::ContractInfo; use dojo_world::diff::WorldDiff; use dojo_world::local::WorldLocal; use katana_rpc_api::starknet::RPC_SPEC_VERSION; @@ -205,6 +207,28 @@ fn is_compatible_version(provided_version: &str, expected_version: &str) -> Resu Ok(expected_ver_req.matches(&provided_ver)) } +/// Returns the contracts from the manifest or from the diff. +pub async fn contracts_from_manifest_or_diff( + account: AccountOptions, + starknet: StarknetOptions, + world: WorldOptions, + ws: &Workspace<'_>, + force_diff: bool, +) -> Result> { + let local_manifest = ws.read_manifest_profile()?; + + let contracts: HashMap = if force_diff || local_manifest.is_none() { + let (world_diff, _, _) = + get_world_diff_and_account(account, starknet, world, ws, &mut None).await?; + + (&world_diff).into() + } else { + (&local_manifest.unwrap()).into() + }; + + Ok(contracts) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/dojo/types/src/naming.rs b/crates/dojo/types/src/naming.rs index 9fd1c2f364..5bbce3ca17 100644 --- a/crates/dojo/types/src/naming.rs +++ b/crates/dojo/types/src/naming.rs @@ -92,6 +92,14 @@ pub fn compute_selector_from_tag(tag: &str) -> Felt { compute_selector_from_names(&namespace, &name) } +pub fn compute_selector_from_tag_or_name(tag_or_name: &str) -> Felt { + if is_valid_tag(tag_or_name) { + compute_selector_from_tag(tag_or_name) + } else { + compute_bytearray_hash(tag_or_name) + } +} + pub fn compute_selector_from_names(namespace: &str, name: &str) -> Felt { compute_selector_from_hashes(compute_bytearray_hash(namespace), compute_bytearray_hash(name)) } @@ -175,4 +183,20 @@ mod tests { assert!(!is_valid_tag("-invalid")); assert!(!is_valid_tag("")); } + + #[test] + fn test_compute_selector_from_tag_or_name_tag() { + assert_eq!( + compute_selector_from_tag_or_name("namespace-model"), + compute_selector_from_tag("namespace-model") + ); + } + + #[test] + fn test_compute_selector_from_tag_or_name_name() { + assert_eq!( + compute_selector_from_tag_or_name("namespace"), + compute_bytearray_hash("namespace") + ); + } } diff --git a/crates/dojo/world/src/diff/resource.rs b/crates/dojo/world/src/diff/resource.rs index b2402399c9..3a33f90fcd 100644 --- a/crates/dojo/world/src/diff/resource.rs +++ b/crates/dojo/world/src/diff/resource.rs @@ -52,6 +52,10 @@ impl DiffPermissions { pub fn synced(&self) -> HashSet { self.local.intersection(&self.remote).cloned().collect() } + + pub fn is_empty(&self) -> bool { + self.local.is_empty() && self.remote.is_empty() + } } impl ResourceDiff { diff --git a/crates/sozo/ops/src/auth.rs b/crates/sozo/ops/src/auth.rs index 76e0a07b2f..21698a989c 100644 --- a/crates/sozo/ops/src/auth.rs +++ b/crates/sozo/ops/src/auth.rs @@ -8,15 +8,9 @@ use dojo_world::contracts::naming::{ }; use dojo_world::contracts::world::WorldContract; use dojo_world::contracts::WorldContractReader; -use scarb_ui::Ui; -#[cfg(feature = "walnut")] -use sozo_walnut::WalnutDebugger; use starknet::accounts::{Account, ConnectedAccount}; use starknet::core::types::{BlockId, BlockTag, Felt}; -//use crate::migration::ui::MigrationUi; -use crate::utils; - #[derive(Debug, Clone, PartialEq)] pub enum ResourceType { Contract(String), diff --git a/crates/sozo/ops/src/migration_ui.rs b/crates/sozo/ops/src/migration_ui.rs index d3b325594a..df2e31cd94 100644 --- a/crates/sozo/ops/src/migration_ui.rs +++ b/crates/sozo/ops/src/migration_ui.rs @@ -32,6 +32,15 @@ impl MigrationUi { } } + /// Returns a new instance with the given frames. + pub fn new_with_frames(text: &'static str, frames: Vec<&'static str>) -> Self { + let frames = + spinners::SpinnerFrames { interval: 500, frames: frames.into_iter().collect() }; + + let spinner = Spinner::new(frames.clone(), text, None); + Self { spinner, default_frames: frames, silent: false } + } + /// Returns a new instance with the silent flag set. pub fn with_silent(mut self) -> Self { self.silent = true; From 9675efab6905420274acc9b3f1da323a1858e561 Mon Sep 17 00:00:00 2001 From: glihm Date: Thu, 7 Nov 2024 22:31:21 -0600 Subject: [PATCH 2/2] feat: add permission cloning --- bin/sozo/src/commands/auth.rs | 289 +++++++++++++++++++++++-------- bin/sozo/src/commands/execute.rs | 3 - bin/sozo/src/utils.rs | 13 ++ 3 files changed, 231 insertions(+), 74 deletions(-) diff --git a/bin/sozo/src/commands/auth.rs b/bin/sozo/src/commands/auth.rs index 2f3fef939e..8ffb186799 100644 --- a/bin/sozo/src/commands/auth.rs +++ b/bin/sozo/src/commands/auth.rs @@ -1,24 +1,20 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::str::FromStr; use anyhow::{anyhow, Result}; use cainome::cairo_serde::ContractAddress; use clap::{Args, Subcommand}; use colored::Colorize; -use dojo_utils::{Invoker, TxnConfig}; -use dojo_world::config::{calldata_decoder, ProfileConfig}; +use dojo_utils::Invoker; +use dojo_world::config::ProfileConfig; use dojo_world::contracts::{ContractInfo, WorldContract}; -use dojo_world::diff::DiffPermissions; +use dojo_world::diff::{DiffPermissions, WorldDiff}; use scarb::core::{Config, Workspace}; use sozo_ops::migration_ui::MigrationUi; -use sozo_ops::resource_descriptor::ResourceDescriptor; use sozo_scarbext::WorkspaceExt; -use sozo_walnut::WalnutDebugger; -use starknet::accounts::ConnectedAccount; -use starknet::core::types::{Call, Felt}; -use starknet::core::utils as snutils; +use starknet::core::types::Felt; use starknet::providers::jsonrpc::HttpTransport; -use starknet::providers::{JsonRpcClient, Provider}; +use starknet::providers::JsonRpcClient; use tracing::trace; use super::options::account::{AccountOptions, SozoAccount}; @@ -69,6 +65,24 @@ pub enum AuthCommand { #[command(flatten)] world: WorldOptions, }, + #[command(about = "Clone all permissions that one contract has to another.")] + Clone { + #[arg(help = "The tag or address of the source contract to clone the permissions from.")] + source: String, + + #[arg(help = "The tag or address of the target contract to clone the permissions to.")] + target: String, + + #[arg( + long, + help = "Revoke the permissions from the source contract after cloning them to the \ + target contract." + )] + revoke_source: bool, + + #[command(flatten)] + common: CommonAuthOptions, + }, } #[derive(Debug, Args)] @@ -140,28 +154,12 @@ impl AuthArgs { match kind { AuthKind::Writer { pairs } => { - let is_writer = true; - update_permissions( - &contracts, - &common, - &profile_config, - pairs, - is_writer, - do_grant, - ) - .await?; + update_writers(&contracts, &common, &profile_config, pairs, do_grant) + .await?; } AuthKind::Owner { pairs } => { - let is_writer = false; - update_permissions( - &contracts, - &common, - &profile_config, - pairs, - is_writer, - do_grant, - ) - .await?; + update_owners(&contracts, &common, &profile_config, pairs, do_grant) + .await?; } } } @@ -179,34 +177,28 @@ impl AuthArgs { match kind { AuthKind::Writer { pairs } => { - let is_writer = true; - update_permissions( - &contracts, - &common, - &profile_config, - pairs, - is_writer, - do_grant, - ) - .await?; + update_writers(&contracts, &common, &profile_config, pairs, do_grant) + .await?; } AuthKind::Owner { pairs } => { - let is_writer = false; - update_permissions( - &contracts, - &common, - &profile_config, - pairs, - is_writer, - do_grant, - ) - .await?; + update_owners(&contracts, &common, &profile_config, pairs, do_grant) + .await?; } } } AuthCommand::List { resource, show_address, starknet, world } => { list_permissions(resource, show_address, starknet, world, &ws).await?; } + AuthCommand::Clone { revoke_source, common, source, target } => { + if source == target { + anyhow::bail!( + "Source and target are the same, please specify different source and \ + target." + ); + } + + clone_permissions(common, &ws, revoke_source, source, target).await?; + } }; Ok(()) @@ -214,6 +206,136 @@ impl AuthArgs { } } +/// Clones the permissions from the source contract address to the target contract address. +async fn clone_permissions( + options: CommonAuthOptions, + ws: &Workspace<'_>, + revoke_source: bool, + source_tag_or_address: String, + target_tag_or_address: String, +) -> Result<()> { + let mut migration_ui = MigrationUi::new_with_frames( + "Gathering permissions from the world...", + vec!["🌍", "🔍", "📜"], + ); + + let (world_diff, account, _) = utils::get_world_diff_and_account( + options.account, + options.starknet, + options.world, + ws, + &mut None, + ) + .await?; + + let source_address = resolve_address_or_tag(&source_tag_or_address, &world_diff)?; + let target_address = resolve_address_or_tag(&target_tag_or_address, &world_diff)?; + + let mut writer_of = HashSet::new(); + let mut owner_of = HashSet::new(); + + for (selector, resource) in world_diff.resources.iter() { + let writers = world_diff.get_writers(*selector); + let owners = world_diff.get_owners(*selector); + + if writers.is_empty() && owners.is_empty() { + continue; + } + + // We need to check remote only resources if we want to be exhaustive. + // But in this version, only synced permissions are supported. + + if writers.synced().iter().any(|w| w.address == source_address) { + writer_of.insert(resource.tag().clone()); + } + + if owners.synced().iter().any(|o| o.address == source_address) { + owner_of.insert(resource.tag().clone()); + } + } + + if writer_of.is_empty() && owner_of.is_empty() { + migration_ui.stop(); + + println!("No permissions to clone."); + return Ok(()); + } + + migration_ui.stop(); + + let writers_resource_selectors = writer_of + .iter() + .map(|r| dojo_types::naming::compute_selector_from_tag_or_name(r)) + .collect::>(); + let owners_resource_selectors = owner_of + .iter() + .map(|r| dojo_types::naming::compute_selector_from_tag_or_name(r)) + .collect::>(); + + let writers_of_tags = writer_of.into_iter().collect::>().join(", "); + let owners_of_tags = owner_of.into_iter().collect::>().join(", "); + + println!( + "Confirm the following permissions to be cloned from {} to {}\n writers: {}\n \ + owners: {}", + source_tag_or_address.bright_blue(), + target_tag_or_address.bright_blue(), + writers_of_tags.bright_green(), + owners_of_tags.bright_yellow(), + ); + + let confirm = utils::prompt_confirm("Continue?")?; + if !confirm { + return Ok(()); + } + + let world = WorldContract::new(world_diff.world_info.address, &account); + let mut invoker = Invoker::new(&account, options.transaction.clone().try_into()?); + + for w in writers_resource_selectors.iter() { + invoker.add_call(world.grant_writer_getcall(w, &ContractAddress(target_address))); + } + + for o in owners_resource_selectors.iter() { + invoker.add_call(world.grant_owner_getcall(o, &ContractAddress(target_address))); + } + + if revoke_source { + println!( + "{}", + format!("\n!Permissions from {} will be revoked!", source_tag_or_address).bright_red() + ); + if !utils::prompt_confirm("Continue?")? { + return Ok(()); + } + + for w in writers_resource_selectors.iter() { + invoker.add_call(world.revoke_writer_getcall(w, &ContractAddress(source_address))); + } + + for o in owners_resource_selectors.iter() { + invoker.add_call(world.revoke_owner_getcall(o, &ContractAddress(source_address))); + } + } + + let res = invoker.multicall().await?; + println!("{}", res); + + Ok(()) +} + +/// Resolves the address or tag to an address. +fn resolve_address_or_tag(address_or_tag: &str, world_diff: &WorldDiff) -> Result { + if address_or_tag.starts_with("0x") { + Felt::from_str(address_or_tag) + .map_err(|_| anyhow!("Invalid contract address: {}", address_or_tag)) + } else { + world_diff + .get_contract_address_from_tag(address_or_tag) + .ok_or_else(|| anyhow!("Contract {} not found.", address_or_tag)) + } +} + /// Lists the permissions of a resource. async fn list_permissions( resource: Option, @@ -344,41 +466,24 @@ fn print_diff_permissions(diff: &DiffPermissions, show_address: bool) { } } -/// Updates the permissions of a resource for a contract. -async fn update_permissions( +/// Updates the owners permissions. +async fn update_owners( contracts: &HashMap, options: &CommonAuthOptions, profile_config: &ProfileConfig, pairs: Vec, - is_writer: bool, do_grant: bool, ) -> Result<()> { let selectors_addresses = pairs .iter() - .map(|p| p.to_selector_and_address(&contracts)) + .map(|p| p.to_selector_and_address(contracts)) .collect::>>()?; let world = get_world_contract(contracts, options, profile_config).await?; let mut invoker = Invoker::new(&world.account, options.transaction.clone().try_into()?); for (selector, address) in selectors_addresses { - let call = if is_writer { - if do_grant { - trace!( - selector = format!("{:#066x}", selector), - address = format!("{:#066x}", address), - "Grant writer call." - ); - world.grant_writer_getcall(&selector, &ContractAddress(address)) - } else { - trace!( - selector = format!("{:#066x}", selector), - address = format!("{:#066x}", address), - "Revoke writer call." - ); - world.revoke_writer_getcall(&selector, &ContractAddress(address)) - } - } else if do_grant { + let call = if do_grant { trace!( selector = format!("{:#066x}", selector), address = format!("{:#066x}", address), @@ -403,6 +508,48 @@ async fn update_permissions( Ok(()) } +/// Updates the writers permissions. +async fn update_writers( + contracts: &HashMap, + options: &CommonAuthOptions, + profile_config: &ProfileConfig, + pairs: Vec, + do_grant: bool, +) -> Result<()> { + let selectors_addresses = pairs + .iter() + .map(|p| p.to_selector_and_address(contracts)) + .collect::>>()?; + + let world = get_world_contract(contracts, options, profile_config).await?; + + let mut invoker = Invoker::new(&world.account, options.transaction.clone().try_into()?); + for (selector, address) in selectors_addresses { + let call = if do_grant { + trace!( + selector = format!("{:#066x}", selector), + address = format!("{:#066x}", address), + "Grant writer call." + ); + world.grant_writer_getcall(&selector, &ContractAddress(address)) + } else { + trace!( + selector = format!("{:#066x}", selector), + address = format!("{:#066x}", address), + "Revoke writer call." + ); + world.revoke_writer_getcall(&selector, &ContractAddress(address)) + }; + + invoker.add_call(call); + } + + let res = invoker.multicall().await?; + println!("{}", res); + + Ok(()) +} + /// Gets the world contract from the contracts map and initializes a world contract instance /// from the environment. async fn get_world_contract( diff --git a/bin/sozo/src/commands/execute.rs b/bin/sozo/src/commands/execute.rs index 57d62ea18d..c8510d1d16 100644 --- a/bin/sozo/src/commands/execute.rs +++ b/bin/sozo/src/commands/execute.rs @@ -1,10 +1,7 @@ -use std::collections::HashMap; - use anyhow::{anyhow, Result}; use clap::Args; use dojo_utils::{Invoker, TxnConfig}; use dojo_world::config::calldata_decoder; -use dojo_world::contracts::ContractInfo; use scarb::core::Config; use sozo_ops::resource_descriptor::ResourceDescriptor; use sozo_scarbext::WorkspaceExt; diff --git a/bin/sozo/src/utils.rs b/bin/sozo/src/utils.rs index 16e72456d4..cc96db910a 100644 --- a/bin/sozo/src/utils.rs +++ b/bin/sozo/src/utils.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::io::{self, Write}; use std::str::FromStr; use anyhow::{anyhow, Context, Result}; @@ -208,6 +209,7 @@ fn is_compatible_version(provided_version: &str, expected_version: &str) -> Resu } /// Returns the contracts from the manifest or from the diff. +#[allow(clippy::unnecessary_unwrap)] pub async fn contracts_from_manifest_or_diff( account: AccountOptions, starknet: StarknetOptions, @@ -229,6 +231,17 @@ pub async fn contracts_from_manifest_or_diff( Ok(contracts) } +/// Prompts the user to confirm an operation. +pub fn prompt_confirm(prompt: &str) -> Result { + print!("{} [y/N]", prompt); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + Ok(input.trim().to_lowercase() == "y") +} + #[cfg(test)] mod tests { use super::*;