From 4cac0107e0ca08788d309f69be754b639fa76f97 Mon Sep 17 00:00:00 2001 From: "remy.baranx@gmail.com" Date: Wed, 20 Mar 2024 15:49:47 +0700 Subject: [PATCH] sozo: store ABI and source in metadata registry This PR updates the `sozo build` command to save ABI and expanded source files, into the `target` directory, for the world contract and every user contracts. These ABI and source files are then uploaded as IPFS artifacts in the `ResourceMetadata` registry, for the world, models and contracts. --- Cargo.lock | 1 + bin/sozo/src/commands/dev.rs | 2 +- bin/sozo/src/commands/migrate.rs | 2 +- bin/sozo/src/utils.rs | 2 +- crates/dojo-lang/Cargo.toml | 2 +- crates/dojo-lang/src/compiler.rs | 36 +- crates/dojo-test-utils/src/compiler.rs | 41 +- crates/dojo-world/src/metadata.rs | 209 +++++++- crates/dojo-world/src/metadata_test.rs | 117 ++++- .../src/metadata_test_data/abi.json | 17 + .../src/metadata_test_data/source.cairo | 79 +++ crates/dojo-world/src/migration/mod.rs | 1 + crates/sozo/ops/Cargo.toml | 1 + crates/sozo/ops/src/migration/mod.rs | 380 +++++++++----- crates/sozo/ops/src/tests/migration.rs | 496 ++++++++++++++++++ crates/sozo/ops/src/tests/mod.rs | 1 + crates/sozo/ops/src/tests/setup.rs | 50 +- .../torii/graphql/src/tests/metadata_test.rs | 6 +- crates/torii/libp2p/src/server/mod.rs | 2 +- 19 files changed, 1271 insertions(+), 174 deletions(-) create mode 100644 crates/dojo-world/src/metadata_test_data/abi.json create mode 100644 crates/dojo-world/src/metadata_test_data/source.cairo create mode 100644 crates/sozo/ops/src/tests/migration.rs diff --git a/Cargo.lock b/Cargo.lock index 86b07d5fe3..b85184b9f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11135,6 +11135,7 @@ dependencies = [ "dojo-types", "dojo-world", "futures", + "ipfs-api-backend-hyper", "katana-runner", "notify", "notify-debouncer-mini", diff --git a/bin/sozo/src/commands/dev.rs b/bin/sozo/src/commands/dev.rs index 4c03b9d749..f92eda68d4 100644 --- a/bin/sozo/src/commands/dev.rs +++ b/bin/sozo/src/commands/dev.rs @@ -199,7 +199,7 @@ impl DevArgs { let env_metadata = if config.manifest_path().exists() { let ws = scarb::ops::read_workspace(config.manifest_path(), config)?; - dojo_metadata_from_workspace(&ws).and_then(|inner| inner.env().cloned()) + dojo_metadata_from_workspace(&ws).env().cloned() } else { None }; diff --git a/bin/sozo/src/commands/migrate.rs b/bin/sozo/src/commands/migrate.rs index 849fe88462..fc56cdee0e 100644 --- a/bin/sozo/src/commands/migrate.rs +++ b/bin/sozo/src/commands/migrate.rs @@ -129,7 +129,7 @@ impl MigrateArgs { let ws = scarb::ops::read_workspace(config.manifest_path(), config)?; let env_metadata = if config.manifest_path().exists() { - dojo_metadata_from_workspace(&ws).and_then(|inner| inner.env().cloned()) + dojo_metadata_from_workspace(&ws).env().cloned() } else { None }; diff --git a/bin/sozo/src/utils.rs b/bin/sozo/src/utils.rs index 8bd219e5b7..7dbbfe28fd 100644 --- a/bin/sozo/src/utils.rs +++ b/bin/sozo/src/utils.rs @@ -25,7 +25,7 @@ pub fn load_metadata_from_config(config: &Config) -> Result, let env_metadata = if config.manifest_path().exists() { let ws = scarb::ops::read_workspace(config.manifest_path(), config)?; - dojo_metadata_from_workspace(&ws).and_then(|inner| inner.env().cloned()) + dojo_metadata_from_workspace(&ws).env().cloned() } else { None }; diff --git a/crates/dojo-lang/Cargo.toml b/crates/dojo-lang/Cargo.toml index 277d7ea15c..2541730c92 100644 --- a/crates/dojo-lang/Cargo.toml +++ b/crates/dojo-lang/Cargo.toml @@ -16,6 +16,7 @@ cairo-lang-debug.workspace = true cairo-lang-defs.workspace = true cairo-lang-diagnostics.workspace = true cairo-lang-filesystem.workspace = true +cairo-lang-formatter.workspace = true cairo-lang-lowering.workspace = true cairo-lang-parser.workspace = true cairo-lang-plugins.workspace = true @@ -50,7 +51,6 @@ tracing.workspace = true url = "2.2.2" [dev-dependencies] -cairo-lang-formatter.workspace = true cairo-lang-semantic.workspace = true cairo-lang-test-utils.workspace = true dojo-test-utils = { path = "../dojo-test-utils" } diff --git a/crates/dojo-lang/src/compiler.rs b/crates/dojo-lang/src/compiler.rs index 0f7d7d4163..010dab774a 100644 --- a/crates/dojo-lang/src/compiler.rs +++ b/crates/dojo-lang/src/compiler.rs @@ -1,4 +1,5 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::io::Write; use std::iter::zip; use std::ops::DerefMut; @@ -8,13 +9,14 @@ use cairo_lang_defs::db::DefsGroup; use cairo_lang_defs::ids::{ModuleId, ModuleItemId}; use cairo_lang_filesystem::db::FilesGroup; use cairo_lang_filesystem::ids::{CrateId, CrateLongId}; +use cairo_lang_formatter::format_string; use cairo_lang_semantic::db::SemanticGroup; use cairo_lang_starknet::abi; use cairo_lang_starknet::contract::{find_contracts, ContractDeclaration}; use cairo_lang_starknet::contract_class::{compile_prepared_db, ContractClass}; use cairo_lang_starknet::plugin::aux_data::StarkNetContractAuxData; use cairo_lang_utils::UpcastMut; -use camino::Utf8PathBuf; +use camino::{Utf8Path, Utf8PathBuf}; use convert_case::{Case, Casing}; use dojo_world::manifest::{ AbiFormat, Class, ComputedValueEntrypoint, DojoContract, DojoModel, Manifest, ManifestMethods, @@ -46,6 +48,8 @@ pub const ABIS_DIR: &str = "abis"; pub const CONTRACTS_DIR: &str = "contracts"; pub const MODELS_DIR: &str = "models"; +pub const SOURCES_DIR: &str = "src"; + pub(crate) const LOG_TARGET: &str = "dojo_lang::compiler"; #[cfg(test)] @@ -87,6 +91,8 @@ impl Compiler for DojoCompiler { ) -> Result<()> { let props: Props = unit.target().props()?; let target_dir = unit.target_dir(ws); + let sources_dir = target_dir.child(Utf8Path::new(SOURCES_DIR)); + let compiler_config = build_compiler_config(&unit, ws); let mut main_crate_ids = collect_main_crate_ids(&unit, db); @@ -118,11 +124,31 @@ impl Compiler for DojoCompiler { for (decl, class) in zip(contracts, classes) { let contract_full_path = decl.module_id().full_path(db.upcast_mut()); - let file_name = format!("{contract_full_path}.json"); - let mut file = target_dir.open_rw(file_name.clone(), "output file", ws.config())?; - serde_json::to_writer_pretty(file.deref_mut(), &class) - .with_context(|| format!("failed to serialize contract: {contract_full_path}"))?; + // save expanded contract source file + if let Ok(file_id) = db.module_main_file(decl.module_id()) { + if let Some(file_content) = db.file_content(file_id) { + let src_file_name = format!("{contract_full_path}.cairo").replace("::", "_"); + + let mut file = + sources_dir.open_rw(src_file_name.clone(), "source file", ws.config())?; + file.write(format_string(db, file_content.to_string()).as_bytes()) + .with_context(|| { + format!("failed to serialize contract source: {contract_full_path}") + })?; + } else { + return Err(anyhow!("failed to get source file content: {contract_full_path}")); + } + } else { + return Err(anyhow!("failed to get source file: {contract_full_path}")); + } + + // save JSON artifact file + let file_name = format!("{contract_full_path}.json"); + let mut file = target_dir.open_rw(file_name.clone(), "class file", ws.config())?; + serde_json::to_writer_pretty(file.deref_mut(), &class).with_context(|| { + format!("failed to serialize contract artifact: {contract_full_path}") + })?; let class_hash = compute_class_hash_of_contract_class(&class).with_context(|| { format!("problem computing class hash for contract `{contract_full_path}`") diff --git a/crates/dojo-test-utils/src/compiler.rs b/crates/dojo-test-utils/src/compiler.rs index 2486ec1eac..335c86f63c 100644 --- a/crates/dojo-test-utils/src/compiler.rs +++ b/crates/dojo-test-utils/src/compiler.rs @@ -11,25 +11,40 @@ use scarb::ops; use scarb_ui::Verbosity; pub fn build_test_config(path: &str) -> anyhow::Result { + build_full_test_config(path, true) +} + +pub fn build_full_test_config(path: &str, override_dirs: bool) -> anyhow::Result { let mut compilers = CompilerRepository::empty(); compilers.add(Box::new(DojoCompiler)).unwrap(); let cairo_plugins = CairoPluginRepository::default(); + let path = Utf8PathBuf::from_path_buf(path.into()).unwrap(); - let cache_dir = TempDir::new().unwrap(); - let config_dir = TempDir::new().unwrap(); - let target_dir = TempDir::new().unwrap(); + if override_dirs { + let cache_dir = TempDir::new().unwrap(); + let config_dir = TempDir::new().unwrap(); + let target_dir = TempDir::new().unwrap(); - let path = Utf8PathBuf::from_path_buf(path.into()).unwrap(); - Config::builder(path.canonicalize_utf8().unwrap()) - .global_cache_dir_override(Some(Utf8Path::from_path(cache_dir.path()).unwrap())) - .global_config_dir_override(Some(Utf8Path::from_path(config_dir.path()).unwrap())) - .target_dir_override(Some(Utf8Path::from_path(target_dir.path()).unwrap().to_path_buf())) - .ui_verbosity(Verbosity::Verbose) - .log_filter_directive(env::var_os("SCARB_LOG")) - .compilers(compilers) - .cairo_plugins(cairo_plugins.into()) - .build() + Config::builder(path.canonicalize_utf8().unwrap()) + .global_cache_dir_override(Some(Utf8Path::from_path(cache_dir.path()).unwrap())) + .global_config_dir_override(Some(Utf8Path::from_path(config_dir.path()).unwrap())) + .target_dir_override(Some( + Utf8Path::from_path(target_dir.path()).unwrap().to_path_buf(), + )) + .ui_verbosity(Verbosity::Verbose) + .log_filter_directive(env::var_os("SCARB_LOG")) + .compilers(compilers) + .cairo_plugins(cairo_plugins.into()) + .build() + } else { + Config::builder(path.canonicalize_utf8().unwrap()) + .ui_verbosity(Verbosity::Verbose) + .log_filter_directive(env::var_os("SCARB_LOG")) + .compilers(compilers) + .cairo_plugins(cairo_plugins.into()) + .build() + } } pub fn corelib() -> PathBuf { diff --git a/crates/dojo-world/src/metadata.rs b/crates/dojo-world/src/metadata.rs index a3c2c7d1ca..2d50f5fa70 100644 --- a/crates/dojo-world/src/metadata.rs +++ b/crates/dojo-world/src/metadata.rs @@ -3,24 +3,144 @@ use std::io::Cursor; use std::path::PathBuf; use anyhow::Result; +use camino::Utf8PathBuf; use ipfs_api_backend_hyper::{IpfsApi, IpfsClient, TryFromUri}; use scarb::core::{ManifestMetadata, Workspace}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::json; use url::Url; +use crate::manifest::{BaseManifest, WORLD_CONTRACT_NAME}; + #[cfg(test)] #[path = "metadata_test.rs"] mod test; -pub fn dojo_metadata_from_workspace(ws: &Workspace<'_>) -> Option { - Some(ws.current_package().ok()?.manifest.metadata.dojo()) +pub const IPFS_CLIENT_URL: &str = "https://ipfs.infura.io:5001"; +pub const IPFS_USERNAME: &str = "2EBrzr7ZASQZKH32sl2xWauXPSA"; +pub const IPFS_PASSWORD: &str = "12290b883db9138a8ae3363b6739d220"; + +// copy constants from dojo-lang to avoid circular dependency +pub const MANIFESTS_DIR: &str = "manifests"; +pub const ABIS_DIR: &str = "abis"; +pub const SOURCES_DIR: &str = "src"; +pub const BASE_DIR: &str = "base"; + +fn build_artifact_from_name( + source_dir: &Utf8PathBuf, + abi_dir: &Utf8PathBuf, + element_name: &str, +) -> ArtifactMetadata { + let sanitized_name = element_name.replace("::", "_"); + let abi_file = abi_dir.join(format!("{sanitized_name}.json")); + let src_file = source_dir.join(format!("{sanitized_name}.cairo")); + + ArtifactMetadata { + abi: if abi_file.exists() { Some(Uri::File(abi_file.into_std_path_buf())) } else { None }, + source: if src_file.exists() { + Some(Uri::File(src_file.into_std_path_buf())) + } else { + None + }, + } } +/// Build world metadata with data read from the project configuration. +/// +/// # Arguments +/// +/// * `project_metadata` - The project metadata. +/// +/// # Returns +/// +/// A [`WorldMetadata`] object initialized with project metadata. +pub fn project_to_world_metadata(project_metadata: Option) -> WorldMetadata { + if let Some(m) = project_metadata { + WorldMetadata { + name: m.name, + description: m.description, + cover_uri: m.cover_uri, + icon_uri: m.icon_uri, + website: m.website, + socials: m.socials, + ..Default::default() + } + } else { + WorldMetadata { + name: None, + description: None, + cover_uri: None, + icon_uri: None, + website: None, + socials: None, + ..Default::default() + } + } +} + +/// Collect metadata from the project configuration and from the workspace. +/// +/// # Arguments +/// `ws`: the workspace. +/// +/// # Returns +/// A [`DojoMetadata`] object containing all Dojo metadata. +pub fn dojo_metadata_from_workspace(ws: &Workspace<'_>) -> DojoMetadata { + let profile = ws.config().profile(); + + let manifest_dir = ws.manifest_path().parent().unwrap().to_path_buf(); + let manifest_dir = manifest_dir.join(MANIFESTS_DIR).join(profile.as_str()); + let target_dir = ws.target_dir().path_existent().unwrap(); + let sources_dir = target_dir.join(profile.as_str()).join(SOURCES_DIR); + let abis_dir = manifest_dir.join(ABIS_DIR).join(BASE_DIR); + + let project_metadata = ws.current_package().unwrap().manifest.metadata.dojo(); + let mut dojo_metadata = + DojoMetadata { env: project_metadata.env.clone(), ..Default::default() }; + + let world_artifact = build_artifact_from_name(&sources_dir, &abis_dir, WORLD_CONTRACT_NAME); + + // inialize Dojo world metadata with world metadata coming from project configuration + dojo_metadata.world = project_to_world_metadata(project_metadata.world); + dojo_metadata.world.artifacts = world_artifact; + + // load models and contracts metadata + if manifest_dir.join(BASE_DIR).exists() { + if let Ok(manifest) = BaseManifest::load_from_path(&manifest_dir.join(BASE_DIR)) { + for model in manifest.models { + let name = model.name.to_string(); + dojo_metadata.artifacts.insert( + name.clone(), + build_artifact_from_name(&sources_dir, &abis_dir.join("models"), &name), + ); + } + + for contract in manifest.contracts { + let name = contract.name.to_string(); + dojo_metadata.artifacts.insert( + name.clone(), + build_artifact_from_name(&sources_dir, &abis_dir.join("contracts"), &name), + ); + } + } + } + + dojo_metadata +} + +/// Metadata coming from project configuration (Scarb.toml) +#[derive(Default, Deserialize, Debug, Clone)] +pub struct ProjectMetadata { + pub world: Option, + pub env: Option, +} + +/// Metadata collected from the project configuration and the Dojo workspace #[derive(Default, Deserialize, Debug, Clone)] -pub struct Metadata { - pub world: Option, +pub struct DojoMetadata { + pub world: WorldMetadata, pub env: Option, + pub artifacts: HashMap, } #[derive(Debug)] @@ -76,6 +196,18 @@ impl Uri { } } +/// World metadata coming from the project configuration (Scarb.toml) +#[derive(Default, Serialize, Deserialize, Debug, Clone)] +pub struct ProjectWorldMetadata { + pub name: Option, + pub description: Option, + pub cover_uri: Option, + pub icon_uri: Option, + pub website: Option, + pub socials: Option>, +} + +/// World metadata collected from the project configuration and the Dojo workspace #[derive(Default, Serialize, Deserialize, Debug, Clone)] pub struct WorldMetadata { pub name: Option, @@ -84,6 +216,14 @@ pub struct WorldMetadata { pub icon_uri: Option, pub website: Option, pub socials: Option>, + pub artifacts: ArtifactMetadata, +} + +/// Metadata Artifacts collected for one Dojo element (world, model, contract...) +#[derive(Default, Serialize, Deserialize, Debug, Clone)] +pub struct ArtifactMetadata { + pub abi: Option, + pub source: Option, } #[derive(Default, Deserialize, Clone, Debug)] @@ -122,7 +262,7 @@ impl Environment { } } -impl WorldMetadata { +impl ProjectWorldMetadata { pub fn name(&self) -> Option<&str> { self.name.as_deref() } @@ -135,8 +275,8 @@ impl WorldMetadata { impl WorldMetadata { pub async fn upload(&self) -> Result { let mut meta = self.clone(); - let client = IpfsClient::from_str("https://ipfs.infura.io:5001")? - .with_credentials("2EBrzr7ZASQZKH32sl2xWauXPSA", "12290b883db9138a8ae3363b6739d220"); + let client = + IpfsClient::from_str(IPFS_CLIENT_URL)?.with_credentials(IPFS_USERNAME, IPFS_PASSWORD); if let Some(Uri::File(icon)) = &self.icon_uri { let icon_data = std::fs::read(icon)?; @@ -152,6 +292,20 @@ impl WorldMetadata { meta.cover_uri = Some(Uri::Ipfs(format!("ipfs://{}", response.hash))) }; + if let Some(Uri::File(abi)) = &self.artifacts.abi { + let abi_data = std::fs::read(abi)?; + let reader = Cursor::new(abi_data); + let response = client.add(reader).await?; + meta.artifacts.abi = Some(Uri::Ipfs(format!("ipfs://{}", response.hash))) + }; + + if let Some(Uri::File(source)) = &self.artifacts.source { + let source_data = std::fs::read(source)?; + let reader = Cursor::new(source_data); + let response = client.add(reader).await?; + meta.artifacts.source = Some(Uri::Ipfs(format!("ipfs://{}", response.hash))) + }; + let serialized = json!(meta).to_string(); let reader = Cursor::new(serialized); let response = client.add(reader).await?; @@ -160,26 +314,51 @@ impl WorldMetadata { } } -impl Metadata { - pub fn env(&self) -> Option<&Environment> { - self.env.as_ref() +impl ArtifactMetadata { + pub async fn upload(&self) -> Result { + let mut meta = self.clone(); + let client = + IpfsClient::from_str(IPFS_CLIENT_URL)?.with_credentials(IPFS_USERNAME, IPFS_PASSWORD); + + if let Some(Uri::File(abi)) = &self.abi { + let abi_data = std::fs::read(abi)?; + let reader = Cursor::new(abi_data); + let response = client.add(reader).await?; + meta.abi = Some(Uri::Ipfs(format!("ipfs://{}", response.hash))) + }; + + if let Some(Uri::File(source)) = &self.source { + let source_data = std::fs::read(source)?; + let reader = Cursor::new(source_data); + let response = client.add(reader).await?; + meta.source = Some(Uri::Ipfs(format!("ipfs://{}", response.hash))) + }; + + let serialized = json!(meta).to_string(); + let reader = Cursor::new(serialized); + let response = client.add(reader).await?; + + Ok(response.hash) } +} - pub fn world(&self) -> Option<&WorldMetadata> { - self.world.as_ref() +impl DojoMetadata { + pub fn env(&self) -> Option<&Environment> { + self.env.as_ref() } } + trait MetadataExt { - fn dojo(&self) -> Metadata; + fn dojo(&self) -> ProjectMetadata; } impl MetadataExt for ManifestMetadata { - fn dojo(&self) -> Metadata { + fn dojo(&self) -> ProjectMetadata { self.tool_metadata .as_ref() .and_then(|e| e.get("dojo")) .cloned() - .map(|v| v.try_into::().unwrap_or_default()) + .map(|v| v.try_into::().unwrap_or_default()) .unwrap_or_default() } } diff --git a/crates/dojo-world/src/metadata_test.rs b/crates/dojo-world/src/metadata_test.rs index a6c950fa6c..b30624320f 100644 --- a/crates/dojo-world/src/metadata_test.rs +++ b/crates/dojo-world/src/metadata_test.rs @@ -1,13 +1,18 @@ use std::collections::HashMap; +use camino::Utf8PathBuf; +use dojo_test_utils::compiler::build_full_test_config; +use scarb::ops; use url::Url; -use super::WorldMetadata; -use crate::metadata::{Metadata, Uri}; +use crate::metadata::{ + dojo_metadata_from_workspace, ArtifactMetadata, ProjectMetadata, Uri, WorldMetadata, ABIS_DIR, + BASE_DIR, MANIFESTS_DIR, SOURCES_DIR, +}; #[test] fn check_metadata_deserialization() { - let metadata: Metadata = toml::from_str( + let metadata: ProjectMetadata = toml::from_str( r#" [env] rpc_url = "http://localhost:5050/" @@ -64,9 +69,13 @@ async fn world_metadata_hash_and_upload() { name: Some("Test World".to_string()), description: Some("A world used for testing".to_string()), cover_uri: Some(Uri::File("src/metadata_test_data/cover.png".into())), - icon_uri: None, + icon_uri: Some(Uri::File("src/metadata_test_data/cover.png".into())), website: Some(Url::parse("https://dojoengine.org").unwrap()), socials: Some(HashMap::from([("x".to_string(), "https://x.com/dojostarknet".to_string())])), + artifacts: ArtifactMetadata { + abi: Some(Uri::File("src/metadata_test_data/abi.json".into())), + source: Some(Uri::File("src/metadata_test_data/source.cairo".into())), + }, }; let _ = meta.upload().await.unwrap(); @@ -74,7 +83,7 @@ async fn world_metadata_hash_and_upload() { #[tokio::test] async fn parse_world_metadata_without_socials() { - let metadata: Metadata = toml::from_str( + let metadata: ProjectMetadata = toml::from_str( r#" [env] rpc_url = "http://localhost:5050/" @@ -97,3 +106,101 @@ website = "https://dojoengine.org" assert!(metadata.world.is_some()); } + +#[tokio::test] +async fn get_full_dojo_metadata_from_workspace() { + let config = build_full_test_config("../../examples/spawn-and-move/Scarb.toml", false).unwrap(); + let ws = ops::read_workspace(config.manifest_path(), &config) + .unwrap_or_else(|op| panic!("Error building workspace: {op:?}")); + + let profile = ws.config().profile(); + let manifest_dir = ws.manifest_path().parent().unwrap().to_path_buf(); + let manifest_dir = manifest_dir.join(MANIFESTS_DIR).join(profile.as_str()); + let target_dir = ws.target_dir().path_existent().unwrap(); + let sources_dir = target_dir.join(profile.as_str()).join(SOURCES_DIR); + let abis_dir = manifest_dir.join(ABIS_DIR).join(BASE_DIR); + + let dojo_metadata = dojo_metadata_from_workspace(&ws); + + // env + assert!(dojo_metadata.env.is_some()); + let env = dojo_metadata.env.unwrap(); + + assert!(env.rpc_url.is_some()); + assert!(env.rpc_url.unwrap().eq("http://localhost:5050/")); + + assert!(env.account_address.is_some()); + assert!( + env.account_address + .unwrap() + .eq("0x6162896d1d7ab204c7ccac6dd5f8e9e7c25ecd5ae4fcb4ad32e57786bb46e03") + ); + + assert!(env.private_key.is_some()); + assert!( + env.private_key.unwrap().eq("0x1800000000300000180000000000030000000000003006001800006600") + ); + + assert!(env.world_address.is_some()); + assert!( + env.world_address + .unwrap() + .eq("0x1385f25d20a724edc9c7b3bd9636c59af64cbaf9fcd12f33b3af96b2452f295") + ); + + assert!(env.keystore_path.is_none()); + assert!(env.keystore_password.is_none()); + + // world + assert!(dojo_metadata.world.name.is_some()); + assert!(dojo_metadata.world.name.unwrap().eq("example")); + + assert!(dojo_metadata.world.description.is_some()); + assert!(dojo_metadata.world.description.unwrap().eq("example world")); + + assert!(dojo_metadata.world.cover_uri.is_none()); + assert!(dojo_metadata.world.icon_uri.is_none()); + assert!(dojo_metadata.world.website.is_none()); + assert!(dojo_metadata.world.socials.is_none()); + + check_artifact( + dojo_metadata.world.artifacts, + "dojo_world_world".to_string(), + &abis_dir, + &sources_dir, + ); + + // artifacts + let artifacts = vec![ + ("models", "dojo_examples::actions::actions::moved"), + ("models", "dojo_examples::models::emote_message"), + ("models", "dojo_examples::models::moves"), + ("models", "dojo_examples::models::position"), + ("contracts", "dojo_examples::actions::actions"), + ]; + + for (abi_subdir, name) in artifacts { + let artifact = dojo_metadata.artifacts.get(name); + assert!(artifact.is_some()); + let artifact = artifact.unwrap(); + + let sanitized_name = name.replace("::", "_"); + + check_artifact(artifact.clone(), sanitized_name, &abis_dir.join(abi_subdir), &sources_dir); + } +} + +fn check_artifact( + artifact: ArtifactMetadata, + name: String, + abis_dir: &Utf8PathBuf, + sources_dir: &Utf8PathBuf, +) { + assert!(artifact.abi.is_some()); + let abi = artifact.abi.unwrap(); + assert_eq!(abi, Uri::File(abis_dir.join(format!("{name}.json")).into())); + + assert!(artifact.source.is_some()); + let source = artifact.source.unwrap(); + assert_eq!(source, Uri::File(sources_dir.join(format!("{name}.cairo")).into())); +} diff --git a/crates/dojo-world/src/metadata_test_data/abi.json b/crates/dojo-world/src/metadata_test_data/abi.json new file mode 100644 index 0000000000..78efed0140 --- /dev/null +++ b/crates/dojo-world/src/metadata_test_data/abi.json @@ -0,0 +1,17 @@ +[ + { + "type": "impl", + "name": "WorldProviderImpl", + "interface_name": "dojo::world::IWorldProvider" + }, + { + "type": "struct", + "name": "dojo::world::IWorldDispatcher", + "members": [ + { + "name": "contract_address", + "type": "core::starknet::contract_address::ContractAddress" + } + ] + } +] diff --git a/crates/dojo-world/src/metadata_test_data/source.cairo b/crates/dojo-world/src/metadata_test_data/source.cairo new file mode 100644 index 0000000000..c917342ece --- /dev/null +++ b/crates/dojo-world/src/metadata_test_data/source.cairo @@ -0,0 +1,79 @@ +use starknet::ContractAddress; + +#[derive(Serde, Copy, Drop, Introspect)] +enum Direction { + None, + Left, + Right, + Up, + Down, +} + +impl DirectionIntoFelt252 of Into { + fn into(self: Direction) -> felt252 { + match self { + Direction::None => 0, + Direction::Left => 1, + Direction::Right => 2, + Direction::Up => 3, + Direction::Down => 4, + } + } +} + +#[derive(Model, Copy, Drop, Serde)] +struct Moves { + #[key] + player: ContractAddress, + remaining: u8, + last_direction: Direction +} + +#[derive(Copy, Drop, Serde, Introspect)] +struct Vec2 { + x: u32, + y: u32 +} + +#[derive(Model, Copy, Drop, Serde)] +struct Position { + #[key] + player: ContractAddress, + vec: Vec2, +} + +trait Vec2Trait { + fn is_zero(self: Vec2) -> bool; + fn is_equal(self: Vec2, b: Vec2) -> bool; +} + +impl Vec2Impl of Vec2Trait { + fn is_zero(self: Vec2) -> bool { + if self.x - self.y == 0 { + return true; + } + false + } + + fn is_equal(self: Vec2, b: Vec2) -> bool { + self.x == b.x && self.y == b.y + } +} + +#[cfg(test)] +mod tests { + use super::{Position, Vec2, Vec2Trait}; + + #[test] + #[available_gas(100000)] + fn test_vec_is_zero() { + assert(Vec2Trait::is_zero(Vec2 { x: 0, y: 0 }), 'not zero'); + } + + #[test] + #[available_gas(100000)] + fn test_vec_is_equal() { + let position = Vec2 { x: 420, y: 0 }; + assert(position.is_equal(Vec2 { x: 420, y: 0 }), 'not equal'); + } +} diff --git a/crates/dojo-world/src/migration/mod.rs b/crates/dojo-world/src/migration/mod.rs index 4af66e2ac5..943c47725e 100644 --- a/crates/dojo-world/src/migration/mod.rs +++ b/crates/dojo-world/src/migration/mod.rs @@ -51,6 +51,7 @@ pub struct UpgradeOutput { pub struct RegisterOutput { pub transaction_hash: FieldElement, pub declare_output: Vec, + pub registered_model_names: Vec, } #[derive(Debug, Error)] diff --git a/crates/sozo/ops/Cargo.toml b/crates/sozo/ops/Cargo.toml index 2472bb87fe..077f54ef54 100644 --- a/crates/sozo/ops/Cargo.toml +++ b/crates/sozo/ops/Cargo.toml @@ -49,5 +49,6 @@ cainome = { git = "https://github.com/cartridge-gg/cainome", tag = "v0.2.2" } [dev-dependencies] assert_fs = "1.0.10" dojo-test-utils = { workspace = true, features = [ "build-examples" ] } +ipfs-api-backend-hyper = { git = "https://github.com/ferristseng/rust-ipfs-api", rev = "af2c17f7b19ef5b9898f458d97a90055c3605633", features = [ "with-hyper-rustls" ] } katana-runner.workspace = true snapbox = "0.4.6" diff --git a/crates/sozo/ops/src/migration/mod.rs b/crates/sozo/ops/src/migration/mod.rs index 4c096fe037..74f7c6499e 100644 --- a/crates/sozo/ops/src/migration/mod.rs +++ b/crates/sozo/ops/src/migration/mod.rs @@ -11,7 +11,7 @@ use dojo_world::manifest::{ Manifest, ManifestMethods, OverlayManifest, WorldContract as ManifestWorldContract, WorldMetadata, }; -use dojo_world::metadata::dojo_metadata_from_workspace; +use dojo_world::metadata::{dojo_metadata_from_workspace, ArtifactMetadata}; use dojo_world::migration::contract::ContractMigration; use dojo_world::migration::strategy::{generate_salt, prepare_for_migration, MigrationStrategy}; use dojo_world::migration::world::WorldDiff; @@ -20,6 +20,7 @@ use dojo_world::migration::{ Upgradable, UpgradeOutput, }; use dojo_world::utils::TransactionWaiter; +use futures::future; use scarb::core::Workspace; use scarb_ui::Ui; use starknet::accounts::{Account, ConnectedAccount, SingleOwnerAccount}; @@ -32,9 +33,6 @@ use starknet::core::utils::{ use starknet::providers::{Provider, ProviderError}; use tokio::fs; -#[cfg(test)] -#[path = "migration_test.rs"] -mod migration_test; mod ui; use starknet::signers::Signer; @@ -51,7 +49,15 @@ pub struct MigrationOutput { // If false that means migration got partially completed. pub full: bool, - pub contracts: Vec>, + pub models: Vec, + pub contracts: Vec>, +} + +#[derive(Debug, Default, Clone)] +pub struct ContractMigrationOutput { + name: String, + contract_address: FieldElement, + base_class_hash: FieldElement, } pub async fn migrate( @@ -107,13 +113,40 @@ where let mut strategy = prepare_migration(&target_dir, diff, name.clone(), world_address, &ui)?; let world_address = strategy.world_address().expect("world address must exist"); - let migration_output = if dry_run { + if dry_run { print_strategy(&ui, account.provider(), &strategy).await; - MigrationOutput { world_address, ..Default::default() } + + update_manifests_and_abis( + ws, + local_manifest, + &profile_dir, + &profile_name, + &rpc_url, + world_address, + None, + name.as_ref(), + ) + .await?; } else { // Migrate according to the diff. match apply_diff(ws, account, txn_config, &mut strategy).await { - Ok(migration_output) => migration_output, + Ok(migration_output) => { + update_manifests_and_abis( + ws, + local_manifest.clone(), + &profile_dir, + &profile_name, + &rpc_url, + world_address, + Some(migration_output.clone()), + name.as_ref(), + ) + .await?; + + if !ws.config().offline() { + upload_metadata(ws, account, migration_output).await?; + } + } Err(e) => { update_manifests_and_abis( ws, @@ -121,7 +154,8 @@ where &profile_dir, &profile_name, &rpc_url, - MigrationOutput { world_address, ..Default::default() }, + world_address, + None, name.as_ref(), ) .await?; @@ -130,17 +164,6 @@ where } }; - update_manifests_and_abis( - ws, - local_manifest, - &profile_dir, - &profile_name, - &rpc_url, - migration_output, - name.as_ref(), - ) - .await?; - Ok(()) } @@ -150,11 +173,12 @@ async fn update_manifests_and_abis( profile_dir: &Utf8PathBuf, profile_name: &str, rpc_url: &str, - migration_output: MigrationOutput, + world_address: FieldElement, + migration_output: Option, salt: Option<&String>, ) -> Result<()> { let ui = ws.config().ui(); - ui.print("\nāœØ Updating manifests..."); + ui.print_step(5, "āœØ", "Updating manifests..."); let deployed_path = profile_dir.join("manifest").with_extension("toml"); let deployed_path_json = profile_dir.join("manifest").with_extension("json"); @@ -171,39 +195,43 @@ async fn update_manifests_and_abis( local_manifest.merge_from_previous(previous_manifest); }; - local_manifest.world.inner.address = Some(migration_output.world_address); + local_manifest.world.inner.address = Some(world_address); if let Some(salt) = salt { local_manifest.world.inner.seed = Some(salt.to_owned()); } - if migration_output.world_tx_hash.is_some() { - local_manifest.world.inner.transaction_hash = migration_output.world_tx_hash; - } - if migration_output.world_block_number.is_some() { - local_manifest.world.inner.block_number = migration_output.world_block_number; - } + // when the migration has not been applied because in `plan` mode or because of an error, + // the `migration_output` is empty. + if let Some(migration_output) = migration_output { + if migration_output.world_tx_hash.is_some() { + local_manifest.world.inner.transaction_hash = migration_output.world_tx_hash; + } + if migration_output.world_block_number.is_some() { + local_manifest.world.inner.block_number = migration_output.world_block_number; + } - migration_output.contracts.iter().for_each(|contract_output| { - // ignore failed migration which are represented by None - if let Some(output) = contract_output { - // find the contract in local manifest and update its address and base class hash - let local = local_manifest - .contracts - .iter_mut() - .find(|c| c.name == output.name.as_ref().unwrap()) - .expect("contract got migrated, means it should be present here"); - - let salt = generate_salt(&local.name); - local.inner.address = Some(get_contract_address( - salt, - output.base_class_hash, - &[], - migration_output.world_address, - )); + migration_output.contracts.iter().for_each(|contract_output| { + // ignore failed migration which are represented by None + if let Some(output) = contract_output { + // find the contract in local manifest and update its address and base class hash + let local = local_manifest + .contracts + .iter_mut() + .find(|c| c.name == output.name) + .expect("contract got migrated, means it should be present here"); + + let salt = generate_salt(&local.name); + local.inner.address = Some(get_contract_address( + salt, + output.base_class_hash, + &[], + migration_output.world_address, + )); - local.inner.base_class_hash = output.base_class_hash; - } - }); + local.inner.base_class_hash = output.base_class_hash; + } + }); + } // copy abi files from `abi/base` to `abi/deployments/{chain_id}` and update abi path in // local_manifest @@ -289,7 +317,8 @@ where { let ui = ws.config().ui(); - println!(" "); + ui.print_step(4, "šŸ› ", "Migrating..."); + ui.print(" "); let migration_output = execute_strategy(ws, strategy, account, txn_config) .await @@ -299,7 +328,7 @@ where if migration_output.full { if let Some(block_number) = migration_output.world_block_number { ui.print(format!( - "\nšŸŽ‰ Successfully migrated World on block #{} at address {}", + "\nšŸŽ‰ Successfully migrated World on block #{} at address {}\n", block_number, bold_message(format!( "{:#x}", @@ -308,7 +337,7 @@ where )); } else { ui.print(format!( - "\nšŸŽ‰ Successfully migrated World at address {}", + "\nšŸŽ‰ Successfully migrated World at address {}\n", bold_message(format!( "{:#x}", strategy.world_address().expect("world address must exist") @@ -493,14 +522,6 @@ where }; ui.print_sub(format!("Contract address: {:#x}", world.contract_address)); - - let offline = ws.config().offline(); - - if offline { - ui.print_sub("Skipping metadata upload because of offline mode"); - } else { - upload_metadata(ws, world, migrator, &ui).await?; - } } } None => {} @@ -511,23 +532,25 @@ where world_tx_hash, world_block_number, full: false, + models: vec![], contracts: vec![], }; // Once Torii supports indexing arrays, we should declare and register the // ResourceMetadata model. - match register_models(strategy, migrator, &ui, txn_config).await { - Ok(_) => (), + Ok(output) => { + migration_output.models = output.registered_model_names; + } Err(e) => { ui.anyhow(&e); return Ok(migration_output); } - } + }; match deploy_dojo_contracts(strategy, migrator, &ui, txn_config).await { - Ok(res) => { - migration_output.contracts = res; + Ok(output) => { + migration_output.contracts = output; } Err(e) => { ui.anyhow(&e); @@ -540,53 +563,6 @@ where Ok(migration_output) } -async fn upload_metadata( - ws: &Workspace<'_>, - world: &ContractMigration, - migrator: &SingleOwnerAccount, - ui: &Ui, -) -> Result<(), anyhow::Error> -where - P: Provider + Sync + Send + 'static, - S: Signer + Sync + Send + 'static, -{ - let metadata = dojo_metadata_from_workspace(ws); - if let Some(meta) = metadata.as_ref().and_then(|inner| inner.world()) { - match meta.upload().await { - Ok(hash) => { - let mut encoded_uri = cairo_utils::encode_uri(&format!("ipfs://{hash}"))?; - - // Metadata is expecting an array of capacity 3. - if encoded_uri.len() < 3 { - encoded_uri.extend(vec![FieldElement::ZERO; 3 - encoded_uri.len()]); - } - - let world_metadata = - ResourceMetadata { resource_id: FieldElement::ZERO, metadata_uri: encoded_uri }; - - let InvokeTransactionResult { transaction_hash } = - WorldContract::new(world.contract_address, migrator) - .set_metadata(&world_metadata) - .send() - .await - .map_err(|e| { - ui.verbose(format!("{e:?}")); - anyhow!("Failed to set World metadata: {e}") - })?; - - TransactionWaiter::new(transaction_hash, migrator.provider()).await?; - - ui.print_sub(format!("Set Metadata transaction: {:#x}", transaction_hash)); - ui.print_sub(format!("Metadata uri: ipfs://{hash}")); - } - Err(err) => { - ui.print_sub(format!("Failed to set World metadata:\n{err}")); - } - } - } - Ok(()) -} - enum ContractDeploymentOutput { AlreadyDeployed(FieldElement), Output(DeployOutput), @@ -693,7 +669,7 @@ async fn register_models( migrator: &SingleOwnerAccount, ui: &Ui, txn_config: Option, -) -> Result> +) -> Result where P: Provider + Sync + Send + 'static, S: Signer + Sync + Send + 'static, @@ -701,12 +677,17 @@ where let models = &strategy.models; if models.is_empty() { - return Ok(None); + return Ok(RegisterOutput { + transaction_hash: FieldElement::ZERO, + declare_output: vec![], + registered_model_names: vec![], + }); } ui.print_header(format!("# Models ({})", models.len())); let mut declare_output = vec![]; + let mut registered_model_names = vec![]; for c in models.iter() { ui.print(italic_message(&c.diff.name).to_string()); @@ -741,7 +722,10 @@ where let calls = models .iter() - .map(|c| world.register_model_getcall(&c.diff.local.into())) + .map(|c| { + registered_model_names.push(c.diff.name.clone()); + world.register_model_getcall(&c.diff.local.into()) + }) .collect::>(); let InvokeTransactionResult { transaction_hash } = @@ -754,7 +738,7 @@ where ui.print(format!("All models are registered at: {transaction_hash:#x}")); - Ok(Some(RegisterOutput { transaction_hash, declare_output })) + Ok(RegisterOutput { transaction_hash, declare_output, registered_model_names }) } async fn deploy_dojo_contracts( @@ -762,7 +746,7 @@ async fn deploy_dojo_contracts( migrator: &SingleOwnerAccount, ui: &Ui, txn_config: Option, -) -> Result>> +) -> Result>> where P: Provider + Sync + Send + 'static, S: Signer + Sync + Send + 'static, @@ -793,7 +777,7 @@ where ) .await { - Ok(mut output) => { + Ok(output) => { if let Some(ref declare) = output.declare { ui.print_hidden_sub(format!( "Declare transaction: {:#x}", @@ -819,10 +803,11 @@ where )); ui.print_sub(format!("Contract address: {:#x}", output.contract_address)); } - let name = contract.diff.name.clone(); - - output.name = Some(name); - deploy_output.push(Some(output)); + deploy_output.push(Some(ContractMigrationOutput { + name: name.to_string(), + contract_address: output.contract_address, + base_class_hash: output.base_class_hash, + })); } Err(MigrationError::ContractAlreadyDeployed(contract_address)) => { ui.print_sub(format!("Already deployed: {:#x}", contract_address)); @@ -926,3 +911,154 @@ where ui.print(" "); } } + +/// Upload a metadata as a IPFS artifact and then create a resource to register +/// into the Dojo resource registry. +/// +/// # Arguments +/// * `element_name` - fully qualified name of the element linked to the metadata +/// * `resource_id` - the id of the resource to create. +/// * `artifact` - the artifact to upload on IPFS. +/// +/// # Returns +/// A [`ResourceData`] object to register in the Dojo resource register +/// on success. +/// +async fn upload_on_ipfs_and_create_resource( + ui: &Ui, + element_name: String, + resource_id: FieldElement, + artifact: ArtifactMetadata, +) -> Result { + match artifact.upload().await { + Ok(hash) => { + ui.print_sub(format!("{}: ipfs://{}", element_name, hash)); + create_resource_metadata(resource_id, hash) + } + Err(_) => Err(anyhow!("Failed to upload IPFS resource.")), + } +} + +/// Create a resource to register in the Dojo resource registry. +/// +/// # Arguments +/// * `resource_id` - the ID of the resource +/// * `hash` - the IPFS hash +/// +/// # Returns +/// A [`ResourceData`] object to register in the Dojo resource register +/// on success. +fn create_resource_metadata(resource_id: FieldElement, hash: String) -> Result { + let mut encoded_uri = cairo_utils::encode_uri(&format!("ipfs://{hash}"))?; + + // Metadata is expecting an array of capacity 3. + if encoded_uri.len() < 3 { + encoded_uri.extend(vec![FieldElement::ZERO; 3 - encoded_uri.len()]); + } + + Ok(ResourceMetadata { resource_id, metadata_uri: encoded_uri }) +} + +/// Upload metadata of the world/models/contracts as IPFS artifacts and then +/// register them in the Dojo resource registry. +/// +/// # Arguments +/// +/// * `ws` - the workspace +/// * `migrator` - the account used to migrate +/// * `migration_output` - the output after having applied the migration plan. +pub async fn upload_metadata( + ws: &Workspace<'_>, + migrator: &SingleOwnerAccount, + migration_output: MigrationOutput, +) -> Result<()> +where + P: Provider + Sync + Send + 'static, + S: Signer + Sync + Send + 'static, +{ + let ui = ws.config().ui(); + + ui.print(" "); + ui.print_step(6, "šŸŒ", "Uploading metadata..."); + ui.print(" "); + + let dojo_metadata = dojo_metadata_from_workspace(ws); + let mut ipfs = vec![]; + let mut resources = vec![]; + + // world + if migration_output.world_tx_hash.is_some() { + match dojo_metadata.world.upload().await { + Ok(hash) => { + let resource = create_resource_metadata(FieldElement::ZERO, hash.clone())?; + ui.print_sub(format!("world: ipfs://{}", hash)); + resources.push(resource); + } + Err(err) => { + ui.print_sub(format!("Failed to upload World metadata:\n{err}")); + } + } + } + + // models + if !migration_output.models.is_empty() { + for model_name in migration_output.models { + if let Some(m) = dojo_metadata.artifacts.get(&model_name) { + ipfs.push(upload_on_ipfs_and_create_resource( + &ui, + model_name.clone(), + get_selector_from_name(&model_name).expect("ASCII model name"), + m.clone(), + )); + } + } + } + + // contracts + let migrated_contracts = migration_output.contracts.into_iter().flatten().collect::>(); + + if !migrated_contracts.is_empty() { + for contract in migrated_contracts { + if let Some(m) = dojo_metadata.artifacts.get(&contract.name) { + ipfs.push(upload_on_ipfs_and_create_resource( + &ui, + contract.name.clone(), + contract.contract_address, + m.clone(), + )); + } + } + } + + // upload IPFS + resources.extend( + future::try_join_all(ipfs) + .await + .map_err(|_| anyhow!("Unable to upload IPFS artifacts."))?, + ); + + ui.print("> All IPFS artifacts have been successfully uploaded.".to_string()); + + // update the resource registry + let world = WorldContract::new(migration_output.world_address, migrator); + + let calls = resources.iter().map(|r| world.set_metadata_getcall(r)).collect::>(); + + let InvokeTransactionResult { transaction_hash } = + migrator.execute(calls).send().await.map_err(|e| { + ui.verbose(format!("{e:?}")); + anyhow!("Failed to register metadata into the resource registry: {e}") + })?; + + TransactionWaiter::new(transaction_hash, migrator.provider()).await?; + + ui.print(format!( + "> All metadata have been registered in the resource registry (tx hash: \ + {transaction_hash:#x})" + )); + + ui.print(""); + ui.print("\nāœØ Done."); + + Ok(()) +} diff --git a/crates/sozo/ops/src/tests/migration.rs b/crates/sozo/ops/src/tests/migration.rs new file mode 100644 index 0000000000..d499b8cb5d --- /dev/null +++ b/crates/sozo/ops/src/tests/migration.rs @@ -0,0 +1,496 @@ +use std::str; + +use camino::Utf8Path; +use dojo_lang::compiler::{BASE_DIR, MANIFESTS_DIR}; +use dojo_test_utils::compiler::build_full_test_config; +use dojo_test_utils::sequencer::{ + get_default_test_starknet_config, SequencerConfig, StarknetConfig, TestSequencer, +}; +use dojo_world::contracts::WorldContractReader; +use dojo_world::manifest::{BaseManifest, DeploymentManifest, WORLD_CONTRACT_NAME}; +use dojo_world::metadata::{ + dojo_metadata_from_workspace, ArtifactMetadata, DojoMetadata, Uri, WorldMetadata, + IPFS_CLIENT_URL, IPFS_PASSWORD, IPFS_USERNAME, +}; +use dojo_world::migration::strategy::prepare_for_migration; +use dojo_world::migration::world::WorldDiff; +use dojo_world::migration::TxConfig; +use futures::TryStreamExt; +use ipfs_api_backend_hyper::{HyperBackend, IpfsApi, IpfsClient, TryFromUri}; +use starknet::accounts::{ExecutionEncoding, SingleOwnerAccount}; +use starknet::core::chain_id; +use starknet::core::types::{BlockId, BlockTag}; +use starknet::core::utils::{get_selector_from_name, parse_cairo_short_string}; +use starknet::macros::felt; +use starknet::providers::jsonrpc::HttpTransport; +use starknet::providers::JsonRpcClient; +use starknet::signers::{LocalWallet, SigningKey}; +use starknet_crypto::FieldElement; + +use super::setup::{load_config, setup_migration, setup_ws}; +use crate::migration::{execute_strategy, upload_metadata}; +use crate::utils::get_contract_address_from_reader; + +#[tokio::test(flavor = "multi_thread")] +async fn migrate_with_auto_mine() { + let config = load_config(); + let ws = setup_ws(&config); + + let mut migration = setup_migration().unwrap(); + + let sequencer = + TestSequencer::start(SequencerConfig::default(), get_default_test_starknet_config()).await; + + let mut account = sequencer.account(); + account.set_block_id(BlockId::Tag(BlockTag::Pending)); + + execute_strategy(&ws, &mut migration, &account, None).await.unwrap(); + + sequencer.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn migrate_with_block_time() { + let config = load_config(); + let ws = setup_ws(&config); + + let mut migration = setup_migration().unwrap(); + + let sequencer = TestSequencer::start( + SequencerConfig { block_time: Some(1000), ..Default::default() }, + get_default_test_starknet_config(), + ) + .await; + + let mut account = sequencer.account(); + account.set_block_id(BlockId::Tag(BlockTag::Pending)); + + execute_strategy(&ws, &mut migration, &account, None).await.unwrap(); + sequencer.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn migrate_with_small_fee_multiplier_will_fail() { + let config = load_config(); + let ws = setup_ws(&config); + + let mut migration = setup_migration().unwrap(); + + let sequencer = TestSequencer::start( + Default::default(), + StarknetConfig { disable_fee: false, ..Default::default() }, + ) + .await; + + let account = SingleOwnerAccount::new( + JsonRpcClient::new(HttpTransport::new(sequencer.url())), + LocalWallet::from_signing_key(SigningKey::from_secret_scalar( + sequencer.raw_account().private_key, + )), + sequencer.raw_account().account_address, + chain_id::TESTNET, + ExecutionEncoding::New, + ); + + assert!( + execute_strategy( + &ws, + &mut migration, + &account, + Some(TxConfig { fee_estimate_multiplier: Some(0.2f64), wait: false, receipt: false }), + ) + .await + .is_err() + ); + sequencer.stop().unwrap(); +} + +#[test] +fn migrate_world_without_seed_will_fail() { + let profile_name = "dev"; + let base = "../../../examples/spawn-and-move"; + let target_dir = format!("{}/target/dev", base); + let manifest = BaseManifest::load_from_path( + &Utf8Path::new(base).to_path_buf().join(MANIFESTS_DIR).join(profile_name).join(BASE_DIR), + ) + .unwrap(); + let world = WorldDiff::compute(manifest, None); + let res = prepare_for_migration(None, None, &Utf8Path::new(&target_dir).to_path_buf(), world); + assert!(res.is_err_and(|e| e.to_string().contains("Missing seed for World deployment."))) +} + +#[tokio::test] +async fn migration_from_remote() { + let config = load_config(); + let ws = setup_ws(&config); + + let base = "../../../examples/spawn-and-move"; + let target_dir = format!("{}/target/dev", base); + + let sequencer = + TestSequencer::start(SequencerConfig::default(), get_default_test_starknet_config()).await; + + let account = SingleOwnerAccount::new( + JsonRpcClient::new(HttpTransport::new(sequencer.url())), + LocalWallet::from_signing_key(SigningKey::from_secret_scalar( + sequencer.raw_account().private_key, + )), + sequencer.raw_account().account_address, + chain_id::TESTNET, + ExecutionEncoding::New, + ); + + let profile_name = ws.current_profile().unwrap().to_string(); + + let manifest = BaseManifest::load_from_path( + &Utf8Path::new(base).to_path_buf().join(MANIFESTS_DIR).join(&profile_name).join(BASE_DIR), + ) + .unwrap(); + + let world = WorldDiff::compute(manifest, None); + + let mut migration = prepare_for_migration( + None, + Some(felt!("0x12345")), + &Utf8Path::new(&target_dir).to_path_buf(), + world, + ) + .unwrap(); + + execute_strategy(&ws, &mut migration, &account, None).await.unwrap(); + + let local_manifest = BaseManifest::load_from_path( + &Utf8Path::new(base).to_path_buf().join(MANIFESTS_DIR).join(&profile_name).join(BASE_DIR), + ) + .unwrap(); + + let remote_manifest = DeploymentManifest::load_from_remote( + JsonRpcClient::new(HttpTransport::new(sequencer.url())), + migration.world_address().unwrap(), + ) + .await + .unwrap(); + + sequencer.stop().unwrap(); + + assert_eq!(local_manifest.world.inner.class_hash, remote_manifest.world.inner.class_hash); + assert_eq!(local_manifest.models.len(), remote_manifest.models.len()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn migrate_with_metadata() { + let config = build_full_test_config("../../../examples/spawn-and-move/Scarb.toml", false) + .unwrap_or_else(|c| panic!("Error loading config: {c:?}")); + let ws = setup_ws(&config); + + let mut migration = setup_migration().unwrap(); + + let sequencer = + TestSequencer::start(SequencerConfig::default(), get_default_test_starknet_config()).await; + + let mut account = sequencer.account(); + account.set_block_id(BlockId::Tag(BlockTag::Pending)); + + let output = execute_strategy(&ws, &mut migration, &account, None).await.unwrap(); + + let res = upload_metadata(&ws, &account, output.clone()).await; + assert!(res.is_ok()); + + let provider = sequencer.provider(); + let world_reader = WorldContractReader::new(output.world_address, &provider); + + let client = IpfsClient::from_str(IPFS_CLIENT_URL) + .unwrap_or_else(|_| panic!("Unable to initialize the IPFS Client")) + .with_credentials(IPFS_USERNAME, IPFS_PASSWORD); + + let dojo_metadata = dojo_metadata_from_workspace(&ws); + + // check world metadata + let resource = world_reader.metadata(&FieldElement::ZERO).call().await.unwrap(); + let element_name = WORLD_CONTRACT_NAME.to_string(); + + let full_uri = get_and_check_metadata_uri(&element_name, &resource.metadata_uri); + let resource_bytes = get_ipfs_resource_data(&client, &element_name, &full_uri).await; + + let metadata = resource_bytes_to_world_metadata(&resource_bytes, &element_name); + + assert_eq!(metadata.name, dojo_metadata.world.name, ""); + assert_eq!(metadata.description, dojo_metadata.world.description, ""); + assert_eq!(metadata.cover_uri, dojo_metadata.world.cover_uri, ""); + assert_eq!(metadata.icon_uri, dojo_metadata.world.icon_uri, ""); + assert_eq!(metadata.website, dojo_metadata.world.website, ""); + assert_eq!(metadata.socials, dojo_metadata.world.socials, ""); + + check_artifact_fields( + &client, + &metadata.artifacts, + &dojo_metadata.world.artifacts, + &element_name, + ) + .await; + + // check model metadata + for m in migration.models { + let selector = get_selector_from_name(&m.diff.name).unwrap(); + check_artifact_metadata(&client, &world_reader, selector, &m.diff.name, &dojo_metadata) + .await; + } + + // check contract metadata + for c in migration.contracts { + let contract_address = + get_contract_address_from_reader(&world_reader, c.diff.name.clone()).await.unwrap(); + check_artifact_metadata( + &client, + &world_reader, + contract_address, + &c.diff.name, + &dojo_metadata, + ) + .await; + } +} + +/// Get the hash from a IPFS URI +/// +/// # Arguments +/// +/// * `uri` - a full IPFS URI +/// +/// # Returns +/// +/// A [`String`] containing the hash from the URI. +fn get_hash_from_uri(uri: &str) -> String { + let hash = match uri.strip_prefix("ipfs://") { + Some(s) => s.to_string(), + None => uri.to_owned(), + }; + match hash.strip_suffix('/') { + Some(s) => s.to_string(), + None => hash, + } +} + +/// Check a metadata field which refers to a file. +/// +/// # Arguments +/// +/// * `client` - a IPFS client. +/// * `uri` - the IPFS URI of the abi field. +/// * `expected_uri` - the URI of the expected file. +/// * `field_name` - the field name. +/// * `element_name` - the fully qualified name of the element linked to this field. +async fn check_file_field( + client: &HyperBackend, + uri: &Uri, + expected_uri: &Uri, + field_name: String, + element_name: &String, +) { + if let Uri::Ipfs(uri) = uri { + let resource_data = get_ipfs_resource_data(client, element_name, uri).await; + assert!( + !resource_data.is_empty(), + "{field_name} IPFS artifact for {} is empty", + element_name + ); + + if let Uri::File(f) = expected_uri { + let file_content = std::fs::read_to_string(f).unwrap(); + let resource_content = std::str::from_utf8(&resource_data).unwrap_or_else(|_| { + panic!( + "Unable to stringify resource data for field '{}' of {}", + field_name, element_name + ) + }); + + assert!( + file_content.eq(&resource_content), + "local '{field_name}' content differs from the one uploaded on IPFS for {}", + element_name + ); + } else { + panic!( + "The field '{field_name}' of {} is not a file (Should never happen !)", + element_name + ); + } + } else { + panic!("The '{field_name}' field is not an IPFS artifact for {}", element_name); + } +} + +/// Convert resource bytes to a ArtifactMetadata object. +/// +/// # Arguments +/// +/// * `raw_data` - resource data as bytes. +/// * `element_name` - name of the element linked to this resource. +/// +/// # Returns +/// +/// A [`ArtifactMetadata`] object. +fn resource_bytes_to_metadata(raw_data: &[u8], element_name: &String) -> ArtifactMetadata { + let data = std::str::from_utf8(raw_data) + .unwrap_or_else(|_| panic!("Unable to stringify raw metadata for {}", element_name)); + serde_json::from_str(data) + .unwrap_or_else(|_| panic!("Unable to deserialize metadata for {}", element_name)) +} + +/// Convert resource bytes to a WorldMetadata object. +/// +/// # Arguments +/// +/// * `raw_data` - resource data as bytes. +/// * `element_name` - name of the element linked to this resource. +/// +/// # Returns +/// +/// A [`WorldMetadata`] object. +fn resource_bytes_to_world_metadata(raw_data: &[u8], element_name: &String) -> WorldMetadata { + let data = std::str::from_utf8(raw_data) + .unwrap_or_else(|_| panic!("Unable to stringify raw metadata for {}", element_name)); + serde_json::from_str(data) + .unwrap_or_else(|_| panic!("Unable to deserialize metadata for {}", element_name)) +} + +/// Read the content of a resource identified by its IPFS URI. +/// +/// # Arguments +/// +/// * `client` - a IPFS client. +/// * `element_name` - the name of the element (model or contract) linked to this artifact. +/// * `uri` - the IPFS resource URI. +/// +/// # Returns +/// +/// A [`Vec`] containing the resource content as bytes. +async fn get_ipfs_resource_data( + client: &HyperBackend, + element_name: &String, + uri: &String, +) -> Vec { + let hash = get_hash_from_uri(uri); + + let res = client.cat(&hash).map_ok(|chunk| chunk.to_vec()).try_concat().await; + assert!(res.is_ok(), "Unable to read the IPFS artifact {} for {}", uri, element_name); + + res.unwrap() +} + +/// Check the validity of artifact metadata fields. +/// +/// # Arguments +/// +/// * `client` - a IPFS client. +/// * `metadata` - the metadata to check. +/// * `expected_metadata` - the metadata values coming from local Dojo metadata. +/// * `element_name` - the name of the element linked to this metadata. +async fn check_artifact_fields( + client: &HyperBackend, + metadata: &ArtifactMetadata, + expected_metadata: &ArtifactMetadata, + element_name: &String, +) { + assert!(metadata.abi.is_some(), "'abi' field not set for {}", element_name); + let abi = metadata.abi.as_ref().unwrap(); + let expected_abi = expected_metadata.abi.as_ref().unwrap(); + check_file_field(client, abi, expected_abi, "abi".to_string(), element_name).await; + + assert!(metadata.source.is_some(), "'source' field not set for {}", element_name); + let source = metadata.source.as_ref().unwrap(); + let expected_source = expected_metadata.source.as_ref().unwrap(); + check_file_field(client, source, expected_source, "source".to_string(), element_name).await; +} + +/// Check the validity of a IPFS artifact metadata. +/// +/// # Arguments +/// +/// * `client` - a IPFS client. +/// * `element_name` - the fully qualified name of the element linked to the artifact. +/// * `uri` - the full metadata URI. +/// * `expected_metadata` - the expected metadata values coming from local Dojo metadata. +async fn check_ipfs_metadata( + client: &HyperBackend, + element_name: &String, + uri: &String, + expected_metadata: &ArtifactMetadata, +) { + let resource_bytes = get_ipfs_resource_data(client, element_name, uri).await; + let metadata = resource_bytes_to_metadata(&resource_bytes, element_name); + + check_artifact_fields(client, &metadata, expected_metadata, element_name).await; +} + +/// Rebuild the full metadata URI from an array of 3 FieldElement. +/// +/// # Arguments +/// +/// * `element_name` - name of the element (model or contract) linked to the metadata URI. +/// * `uri` - uri as an array of 3 FieldElement. +/// +/// # Returns +/// +/// A [`String`] containing the full metadata URI. +fn get_and_check_metadata_uri(element_name: &String, uri: &Vec) -> String { + assert!(uri.len() == 3, "bad metadata URI length for {} ({:#?})", element_name, uri); + + let mut i = 0; + let mut full_uri = "".to_string(); + + while i < uri.len() && uri[i] != FieldElement::ZERO { + let uri_str = parse_cairo_short_string(&uri[i]); + assert!( + uri_str.is_ok(), + "unable to parse the part {} of the metadata URI for {}", + i + 1, + element_name + ); + + full_uri = format!("{}{}", full_uri, uri_str.unwrap()); + + i += 1; + } + + assert!(!full_uri.is_empty(), "metadata URI is empty for {}", element_name); + + assert!( + full_uri.starts_with("ipfs://"), + "metadata URI for {} is not an IPFS artifact", + element_name + ); + + full_uri +} + +/// Check an artifact metadata read from the resource registry against its value +/// in the local Dojo metadata. +/// +/// # Arguments +/// +/// * `client` - a IPFS client. +/// * `world_reader` - a world reader object. +/// * `resource_id` - the resource ID in the resource registry. +/// * `element_name` - the fully qualified name of the element linked to this metadata. +/// * `dojo_metadata` - local Dojo metadata. +async fn check_artifact_metadata( + client: &HyperBackend, + world_reader: &WorldContractReader

, + resource_id: FieldElement, + element_name: &String, + dojo_metadata: &DojoMetadata, +) { + let resource = world_reader.metadata(&resource_id).call().await.unwrap(); + + let expected_artifact = dojo_metadata.artifacts.get(element_name); + assert!( + expected_artifact.is_some(), + "Unable to find local artifact metadata for {}", + element_name + ); + let expected_artifact = expected_artifact.unwrap(); + + let full_uri = get_and_check_metadata_uri(element_name, &resource.metadata_uri); + check_ipfs_metadata(client, element_name, &full_uri, expected_artifact).await; +} diff --git a/crates/sozo/ops/src/tests/mod.rs b/crates/sozo/ops/src/tests/mod.rs index 25bdba5697..f754ddc5a6 100644 --- a/crates/sozo/ops/src/tests/mod.rs +++ b/crates/sozo/ops/src/tests/mod.rs @@ -1,4 +1,5 @@ pub mod auth; pub mod call; +pub mod migration; pub mod setup; pub mod utils; diff --git a/crates/sozo/ops/src/tests/setup.rs b/crates/sozo/ops/src/tests/setup.rs index 47eb424524..c55be7c1f4 100644 --- a/crates/sozo/ops/src/tests/setup.rs +++ b/crates/sozo/ops/src/tests/setup.rs @@ -3,7 +3,9 @@ use dojo_test_utils::compiler::build_test_config; use dojo_test_utils::migration::prepare_migration; use dojo_test_utils::sequencer::TestSequencer; use dojo_world::contracts::world::WorldContract; +use dojo_world::migration::strategy::MigrationStrategy; use dojo_world::migration::TxConfig; +use scarb::core::{Config, Workspace}; use scarb::ops; use starknet::accounts::SingleOwnerAccount; use starknet::core::types::{BlockId, BlockTag}; @@ -13,8 +15,47 @@ use starknet::signers::LocalWallet; use crate::migration; +/// Load the spawn-and-moves project configuration. +/// +/// # Returns +/// +/// A [`Config`] object loaded from the spawn-and-moves Scarb.toml file. +pub fn load_config() -> Config { + build_test_config("../../../examples/spawn-and-move/Scarb.toml") + .unwrap_or_else(|c| panic!("Error loading config: {c:?}")) +} + +/// Setups the workspace for the spawn-and-moves project. +/// +/// # Arguments +/// * `config` - the project configuration. +/// +/// # Returns +/// +/// A [`Workspace`] loaded from the spawn-and-moves project. +pub fn setup_ws(config: &Config) -> Workspace<'_> { + ops::read_workspace(config.manifest_path(), config) + .unwrap_or_else(|op| panic!("Error building workspace: {op:?}")) +} + +/// Prepare the migration for the spawn-and-moves project. +/// +/// # Returns +/// +/// A [`MigrationStrategy`] to execute to migrate the full spawn-and-moves project. +pub fn setup_migration() -> Result { + let base_dir = "../../../examples/spawn-and-move"; + let target_dir = format!("{}/target/dev", base_dir); + + prepare_migration(base_dir.into(), target_dir.into()) +} + /// Setups the project by migrating the full spawn-and-moves project. /// +/// # Arguments +/// +/// * `sequencer` - The sequencer used for tests. +/// /// # Returns /// /// A [`WorldContract`] initialized with the migrator account, @@ -22,13 +63,10 @@ use crate::migration; pub async fn setup( sequencer: &TestSequencer, ) -> Result, LocalWallet>>> { - let config = build_test_config("../../../examples/spawn-and-move/Scarb.toml")?; - let ws = ops::read_workspace(config.manifest_path(), &config) - .unwrap_or_else(|op| panic!("Error building workspace: {op:?}")); - let base_dir = "../../../examples/spawn-and-move"; - let target_dir = format!("{}/target/dev", base_dir); + let config = load_config(); + let ws = setup_ws(&config); - let mut migration = prepare_migration(base_dir.into(), target_dir.into())?; + let mut migration = setup_migration()?; let mut account = sequencer.account(); account.set_block_id(BlockId::Tag(BlockTag::Pending)); diff --git a/crates/torii/graphql/src/tests/metadata_test.rs b/crates/torii/graphql/src/tests/metadata_test.rs index 01914abfb7..c834ea1d3c 100644 --- a/crates/torii/graphql/src/tests/metadata_test.rs +++ b/crates/torii/graphql/src/tests/metadata_test.rs @@ -1,6 +1,6 @@ #[cfg(test)] mod tests { - use dojo_world::metadata::Metadata as DojoMetadata; + use dojo_world::metadata::{project_to_world_metadata, ProjectMetadata}; use sqlx::SqlitePool; use starknet_crypto::FieldElement; use torii_core::sql::Sql; @@ -51,7 +51,7 @@ mod tests { let schema = build_schema(&pool).await.unwrap(); let cover_img = "QWxsIHlvdXIgYmFzZSBiZWxvbmcgdG8gdXM="; - let dojo_metadata: DojoMetadata = toml::from_str( + let project_metadata: ProjectMetadata = toml::from_str( r#" [world] name = "example" @@ -62,7 +62,7 @@ mod tests { "#, ) .unwrap(); - let world_metadata = dojo_metadata.world.unwrap(); + let world_metadata = project_to_world_metadata(project_metadata.world); db.set_metadata(&RESOURCE, URI, BLOCK_TIMESTAMP); db.update_metadata(&RESOURCE, URI, &world_metadata, &None, &Some(cover_img.to_string())) .await diff --git a/crates/torii/libp2p/src/server/mod.rs b/crates/torii/libp2p/src/server/mod.rs index 36df964fed..8806c46040 100644 --- a/crates/torii/libp2p/src/server/mod.rs +++ b/crates/torii/libp2p/src/server/mod.rs @@ -630,7 +630,7 @@ async fn validate_message( } else { return Err(Error::InvalidMessageError("Model name is missing".to_string())); }; - let model_selector = get_selector_from_name(&model_name).map_err(|e| { + let model_selector = get_selector_from_name(model_name).map_err(|e| { Error::InvalidMessageError(format!("Failed to get selector from model name: {}", e)) })?;