diff --git a/Cargo.lock b/Cargo.lock index 2adfe23f00..e13bfaf718 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4915,6 +4915,7 @@ dependencies = [ "cainome 0.4.6", "cairo-lang-starknet-classes", "dojo-types 1.0.2", + "futures", "hex", "hex-literal", "ipfs-api-backend-hyper", @@ -13723,7 +13724,6 @@ dependencies = [ "dojo-utils", "dojo-world", "futures", - "ipfs-api-backend-hyper", "katana-runner", "num-traits 0.2.19", "scarb", diff --git a/Cargo.toml b/Cargo.toml index d2350365d1..0afa259f3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -140,28 +140,28 @@ auto_impl = "1.2.0" base64 = "0.21.2" bigdecimal = "0.4.1" bytes = "1.6" -cairo-lang-compiler = "2.8.4" -cairo-lang-debug = "2.8.4" -cairo-lang-defs = "2.8.4" +cairo-lang-compiler = "=2.8.4" +cairo-lang-debug = "=2.8.4" +cairo-lang-defs = "=2.8.4" cairo-lang-diagnostics = "2.7.0" -cairo-lang-filesystem = "2.8.4" -cairo-lang-formatter = "2.8.4" -cairo-lang-language-server = "2.8.4" -cairo-lang-lowering = "2.8.4" -cairo-lang-parser = "2.8.4" -cairo-lang-plugins = { version = "2.8.4", features = [ "testing" ] } -cairo-lang-project = "2.8.4" -cairo-lang-semantic = "2.8.4" -cairo-lang-sierra = "2.8.4" -cairo-lang-sierra-generator = "2.8.4" -cairo-lang-sierra-to-casm = "2.8.4" -cairo-lang-starknet = "2.8.4" -cairo-lang-starknet-classes = "2.8.4" -cairo-lang-syntax = "2.8.4" -cairo-lang-test-plugin = "2.8.4" -cairo-lang-test-runner = "2.8.4" -cairo-lang-test-utils = "2.8.4" -cairo-lang-utils = "2.8.4" +cairo-lang-filesystem = "=2.8.4" +cairo-lang-formatter = "=2.8.4" +cairo-lang-language-server = "=2.8.4" +cairo-lang-lowering = "=2.8.4" +cairo-lang-parser = "=2.8.4" +cairo-lang-plugins = { version = "=2.8.4", features = [ "testing" ] } +cairo-lang-project = "=2.8.4" +cairo-lang-semantic = "=2.8.4" +cairo-lang-sierra = "=2.8.4" +cairo-lang-sierra-generator = "=2.8.4" +cairo-lang-sierra-to-casm = "=2.8.4" +cairo-lang-starknet = "=2.8.4" +cairo-lang-starknet-classes = "=2.8.4" +cairo-lang-syntax = "=2.8.4" +cairo-lang-test-plugin = "=2.8.4" +cairo-lang-test-runner = "=2.8.4" +cairo-lang-test-utils = "=2.8.4" +cairo-lang-utils = "=2.8.4" cairo-vm = "1.0.0-rc4" camino = { version = "1.1.2", features = [ "serde1" ] } chrono = { version = "0.4.24", features = [ "serde" ] } diff --git a/bin/sozo/src/commands/auth.rs b/bin/sozo/src/commands/auth.rs index 121304e46b..10e02841ce 100644 --- a/bin/sozo/src/commands/auth.rs +++ b/bin/sozo/src/commands/auth.rs @@ -7,6 +7,7 @@ use clap::{Args, Subcommand}; use colored::Colorize; use dojo_utils::Invoker; use dojo_world::config::ProfileConfig; +use dojo_world::constants::WORLD; use dojo_world::contracts::{ContractInfo, WorldContract}; use dojo_world::diff::{DiffPermissions, WorldDiff}; use scarb::core::{Config, Workspace}; @@ -305,12 +306,12 @@ async fn clone_permissions( writer_of.extend( external_writer_of .iter() - .map(|r| if r != &Felt::ZERO { format!("{:#066x}", r) } else { "World".to_string() }), + .map(|r| if r != &WORLD { format!("{:#066x}", r) } else { "World".to_string() }), ); owner_of.extend( external_owner_of .iter() - .map(|r| if r != &Felt::ZERO { format!("{:#066x}", r) } else { "World".to_string() }), + .map(|r| if r != &WORLD { format!("{:#066x}", r) } else { "World".to_string() }), ); // Sort the tags to have a deterministic output. @@ -417,13 +418,13 @@ async fn list_permissions( let mut world_writers = world_diff .external_writers - .get(&Felt::ZERO) + .get(&WORLD) .map(|writers| writers.iter().cloned().collect::>()) .unwrap_or_default(); let mut world_owners = world_diff .external_owners - .get(&Felt::ZERO) + .get(&WORLD) .map(|owners| owners.iter().cloned().collect::>()) .unwrap_or_default(); @@ -677,7 +678,7 @@ impl PermissionPair { contracts: &HashMap, ) -> Result<(Felt, Felt)> { let selector = if self.resource_tag == "world" { - Felt::ZERO + WORLD } else if self.resource_tag.starts_with("0x") { Felt::from_str(&self.resource_tag) .map_err(|_| anyhow!("Invalid resource selector: {}", self.resource_tag))? @@ -788,7 +789,7 @@ mod tests { grantee_tag_or_address: "0x123".to_string(), }; let (selector, address) = pair.to_selector_and_address(&contracts).unwrap(); - assert_eq!(selector, Felt::ZERO); + assert_eq!(selector, WORLD); assert_eq!(address, Felt::from_str("0x123").unwrap()); let pair = PermissionPair { diff --git a/bin/sozo/src/commands/dev.rs b/bin/sozo/src/commands/dev.rs index 3949a7a155..f981dfef17 100644 --- a/bin/sozo/src/commands/dev.rs +++ b/bin/sozo/src/commands/dev.rs @@ -16,6 +16,7 @@ use super::options::account::AccountOptions; use super::options::starknet::StarknetOptions; use super::options::transaction::TransactionOptions; use super::options::world::WorldOptions; +use crate::commands::options::ipfs::IpfsOptions; #[derive(Debug, Args)] pub struct DevArgs { @@ -82,11 +83,16 @@ impl DevArgs { build_args.clone().run(config)?; info!("Initial build completed."); + // As this `dev` command is for development purpose only, + // allowing to watch for changes, compile and migrate them, + // there is no need for metadata uploading. That's why, + // `ipfs` is set to its default value meaning it is disabled. let migrate_args = MigrateArgs { world: self.world, starknet: self.starknet, account: self.account, transaction: self.transaction, + ipfs: IpfsOptions::default(), }; let _ = migrate_args.clone().run(config); diff --git a/bin/sozo/src/commands/migrate.rs b/bin/sozo/src/commands/migrate.rs index 95a2d33025..ebc37ae0c2 100644 --- a/bin/sozo/src/commands/migrate.rs +++ b/bin/sozo/src/commands/migrate.rs @@ -1,8 +1,9 @@ use anyhow::{Context, Result}; use clap::Args; -use colored::Colorize; +use colored::*; use dojo_utils::{self, TxnConfig}; use dojo_world::contracts::WorldContract; +use dojo_world::services::IpfsService; use scarb::core::{Config, Workspace}; use sozo_ops::migrate::{Migration, MigrationResult}; use sozo_ops::migration_ui::MigrationUi; @@ -14,6 +15,7 @@ use tabled::{Table, Tabled}; use tracing::trace; use super::options::account::AccountOptions; +use super::options::ipfs::IpfsOptions; use super::options::starknet::StarknetOptions; use super::options::transaction::TransactionOptions; use super::options::world::WorldOptions; @@ -32,6 +34,9 @@ pub struct MigrateArgs { #[command(flatten)] pub account: AccountOptions, + + #[command(flatten)] + pub ipfs: IpfsOptions, } impl MigrateArgs { @@ -43,7 +48,7 @@ impl MigrateArgs { ws.profile_check()?; ws.ensure_profile_artifacts()?; - let MigrateArgs { world, starknet, account, .. } = self; + let MigrateArgs { world, starknet, account, ipfs, .. } = self; config.tokio_handle().block_on(async { print_banner(&ws, &starknet).await?; @@ -60,6 +65,7 @@ impl MigrateArgs { .await?; let world_address = world_diff.world_info.address; + let profile_config = ws.load_profile_config()?; let mut txn_config: TxnConfig = self.transaction.try_into()?; txn_config.wait = true; @@ -75,15 +81,45 @@ impl MigrateArgs { let MigrationResult { manifest, has_changes } = migration.migrate(&mut spinner).await.context("Migration failed.")?; + let ipfs_config = + ipfs.config().or(profile_config.env.map(|env| env.ipfs_config).unwrap_or(None)); + + if let Some(config) = ipfs_config { + let mut metadata_service = IpfsService::new(config)?; + + migration + .upload_metadata(&mut spinner, &mut metadata_service) + .await + .context("Metadata upload failed.")?; + } else { + println!(); + println!( + "{}", + "IPFS credentials not found. Metadata upload skipped. To upload metadata, configure IPFS credentials in your profile config or environment variables: https://book.dojoengine.org/framework/world/metadata.".bright_yellow() + ); + }; + spinner.update_text("Writing manifest..."); ws.write_manifest_profile(manifest).context("🪦 Failed to write manifest.")?; let colored_address = format!("{:#066x}", world_address).green(); let (symbol, end_text) = if has_changes { - ("⛩️ ", format!("Migration successful with world at address {}", colored_address)) + ( + "⛩️ ", + format!( + "Migration successful with world at address {}", + colored_address + ), + ) } else { - ("🪨 ", format!("No changes for world at address {:#066x}", world_address)) + ( + "🪨 ", + format!( + "No changes for world at address {:#066x}", + world_address + ), + ) }; spinner.stop_and_persist_boxed(symbol, end_text); diff --git a/bin/sozo/src/commands/options/ipfs.rs b/bin/sozo/src/commands/options/ipfs.rs new file mode 100644 index 0000000000..146899614e --- /dev/null +++ b/bin/sozo/src/commands/options/ipfs.rs @@ -0,0 +1,116 @@ +use clap::Args; +use dojo_utils::env::{IPFS_PASSWORD_ENV_VAR, IPFS_URL_ENV_VAR, IPFS_USERNAME_ENV_VAR}; +use dojo_world::config::IpfsConfig; +use tracing::trace; +use url::Url; + +#[derive(Debug, Default, Args, Clone)] +#[command(next_help_heading = "IPFS options")] +pub struct IpfsOptions { + #[arg(long, env = IPFS_URL_ENV_VAR)] + #[arg(value_name = "URL")] + #[arg(help = "The IPFS URL.")] + #[arg(global = true)] + pub ipfs_url: Option, + + #[arg(long, env = IPFS_USERNAME_ENV_VAR)] + #[arg(value_name = "USERNAME")] + #[arg(help = "The IPFS username.")] + #[arg(global = true)] + pub ipfs_username: Option, + + #[arg(long, env = IPFS_PASSWORD_ENV_VAR)] + #[arg(value_name = "PASSWORD")] + #[arg(help = "The IPFS password.")] + #[arg(global = true)] + pub ipfs_password: Option, +} + +impl IpfsOptions { + pub fn config(&self) -> Option { + trace!("Retrieving IPFS config for IpfsOptions."); + + let url = self.ipfs_url.as_ref().map(|url| url.to_string()); + let username = self.ipfs_username.clone(); + let password = self.ipfs_password.clone(); + + if let (Some(url), Some(username), Some(password)) = (url, username, password) { + Some(IpfsConfig { url, username, password }) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use clap::Parser; + use dojo_utils::env::{IPFS_PASSWORD_ENV_VAR, IPFS_URL_ENV_VAR, IPFS_USERNAME_ENV_VAR}; + + use super::IpfsOptions; + + #[derive(clap::Parser)] + struct Command { + #[clap(flatten)] + options: IpfsOptions, + } + + const ENV_IPFS_URL: &str = "http://ipfs.service/"; + const ENV_IPFS_USERNAME: &str = "johndoe"; + const ENV_IPFS_PASSWORD: &str = "123456"; + + #[test] + fn options_read_from_env_variable() { + std::env::set_var(IPFS_URL_ENV_VAR, ENV_IPFS_URL); + std::env::set_var(IPFS_USERNAME_ENV_VAR, ENV_IPFS_USERNAME); + std::env::set_var(IPFS_PASSWORD_ENV_VAR, ENV_IPFS_PASSWORD); + + let cmd = Command::parse_from([""]); + let config = cmd.options.config().unwrap(); + assert_eq!(config.url, ENV_IPFS_URL.to_string()); + assert_eq!(config.username, ENV_IPFS_USERNAME.to_string()); + assert_eq!(config.password, ENV_IPFS_PASSWORD.to_string()); + } + + #[test] + fn cli_args_override_env_variables() { + std::env::set_var(IPFS_URL_ENV_VAR, ENV_IPFS_URL); + let url = "http://different.url/"; + let username = "bobsmith"; + let password = "654321"; + + let cmd = Command::parse_from([ + "sozo", + "--ipfs-url", + url, + "--ipfs-username", + username, + "--ipfs-password", + password, + ]); + let config = cmd.options.config().unwrap(); + assert_eq!(config.url, url); + assert_eq!(config.username, username); + assert_eq!(config.password, password); + } + + #[test] + fn invalid_url_format() { + let cmd = Command::try_parse_from([ + "sozo", + "--ipfs-url", + "invalid-url", + "--ipfs-username", + "bobsmith", + "--ipfs-password", + "654321", + ]); + assert!(cmd.is_err()); + } + + #[test] + fn options_not_provided_in_env_variable() { + let cmd = Command::parse_from(["sozo"]); + assert!(cmd.options.config().is_none()); + } +} diff --git a/bin/sozo/src/commands/options/mod.rs b/bin/sozo/src/commands/options/mod.rs index a92c824a2d..182bc526fd 100644 --- a/bin/sozo/src/commands/options/mod.rs +++ b/bin/sozo/src/commands/options/mod.rs @@ -1,4 +1,5 @@ pub mod account; +pub mod ipfs; pub mod signer; pub mod starknet; pub mod transaction; diff --git a/bin/sozo/tests/test_data/policies.json b/bin/sozo/tests/test_data/policies.json index b61f8c47b4..26ec67d783 100644 --- a/bin/sozo/tests/test_data/policies.json +++ b/bin/sozo/tests/test_data/policies.json @@ -1,130 +1,130 @@ [ { - "target": "0x70058e3886cb7411e8a77db90ee3dd453ac16b763b30bd99b3c8440fe42056e", - "method": "uuid" + "target": "0x72a9f501c260b2d13f8988ea172680c5c1fdc085c5b44bdcac8477362ed5290", + "method": "upgrade" }, { - "target": "0x70058e3886cb7411e8a77db90ee3dd453ac16b763b30bd99b3c8440fe42056e", - "method": "set_metadata" + "target": "0x7e8a52c68b243d3a86a55c04ccec2edc760252d5952566d3af4001bd6cf38f3", + "method": "spawn" }, { - "target": "0x70058e3886cb7411e8a77db90ee3dd453ac16b763b30bd99b3c8440fe42056e", - "method": "register_namespace" + "target": "0x7e8a52c68b243d3a86a55c04ccec2edc760252d5952566d3af4001bd6cf38f3", + "method": "move" }, { - "target": "0x70058e3886cb7411e8a77db90ee3dd453ac16b763b30bd99b3c8440fe42056e", - "method": "register_event" + "target": "0x7e8a52c68b243d3a86a55c04ccec2edc760252d5952566d3af4001bd6cf38f3", + "method": "set_player_config" }, { - "target": "0x70058e3886cb7411e8a77db90ee3dd453ac16b763b30bd99b3c8440fe42056e", - "method": "register_model" + "target": "0x7e8a52c68b243d3a86a55c04ccec2edc760252d5952566d3af4001bd6cf38f3", + "method": "reset_player_config" }, { - "target": "0x70058e3886cb7411e8a77db90ee3dd453ac16b763b30bd99b3c8440fe42056e", - "method": "register_contract" + "target": "0x7e8a52c68b243d3a86a55c04ccec2edc760252d5952566d3af4001bd6cf38f3", + "method": "set_player_server_profile" }, { - "target": "0x70058e3886cb7411e8a77db90ee3dd453ac16b763b30bd99b3c8440fe42056e", - "method": "init_contract" + "target": "0x7e8a52c68b243d3a86a55c04ccec2edc760252d5952566d3af4001bd6cf38f3", + "method": "set_models" }, { - "target": "0x70058e3886cb7411e8a77db90ee3dd453ac16b763b30bd99b3c8440fe42056e", - "method": "upgrade_event" + "target": "0x7e8a52c68b243d3a86a55c04ccec2edc760252d5952566d3af4001bd6cf38f3", + "method": "enter_dungeon" }, { - "target": "0x70058e3886cb7411e8a77db90ee3dd453ac16b763b30bd99b3c8440fe42056e", - "method": "upgrade_model" + "target": "0x7e8a52c68b243d3a86a55c04ccec2edc760252d5952566d3af4001bd6cf38f3", + "method": "upgrade" }, { - "target": "0x70058e3886cb7411e8a77db90ee3dd453ac16b763b30bd99b3c8440fe42056e", - "method": "upgrade_contract" + "target": "0x50b1497d463d52cbeb5919a35a82360ea6702db2b9c62c2d69c167995f34c08", + "method": "upgrade" }, { - "target": "0x70058e3886cb7411e8a77db90ee3dd453ac16b763b30bd99b3c8440fe42056e", - "method": "emit_event" + "target": "0x4b41a2abaeff170f3a04acb0144790a5a812e25e7a735dfef959247cfeb527", + "method": "upgrade" }, { - "target": "0x70058e3886cb7411e8a77db90ee3dd453ac16b763b30bd99b3c8440fe42056e", - "method": "emit_events" + "target": "0x52ee4d3cba58d1a0462bbfb6813bf5aa1b35078c3b859cded2b727c1d9469ea", + "method": "uuid" }, { - "target": "0x70058e3886cb7411e8a77db90ee3dd453ac16b763b30bd99b3c8440fe42056e", - "method": "set_entity" + "target": "0x52ee4d3cba58d1a0462bbfb6813bf5aa1b35078c3b859cded2b727c1d9469ea", + "method": "set_metadata" }, { - "target": "0x70058e3886cb7411e8a77db90ee3dd453ac16b763b30bd99b3c8440fe42056e", - "method": "set_entities" + "target": "0x52ee4d3cba58d1a0462bbfb6813bf5aa1b35078c3b859cded2b727c1d9469ea", + "method": "register_namespace" }, { - "target": "0x70058e3886cb7411e8a77db90ee3dd453ac16b763b30bd99b3c8440fe42056e", - "method": "delete_entity" + "target": "0x52ee4d3cba58d1a0462bbfb6813bf5aa1b35078c3b859cded2b727c1d9469ea", + "method": "register_event" }, { - "target": "0x70058e3886cb7411e8a77db90ee3dd453ac16b763b30bd99b3c8440fe42056e", - "method": "delete_entities" + "target": "0x52ee4d3cba58d1a0462bbfb6813bf5aa1b35078c3b859cded2b727c1d9469ea", + "method": "register_model" }, { - "target": "0x70058e3886cb7411e8a77db90ee3dd453ac16b763b30bd99b3c8440fe42056e", - "method": "grant_owner" + "target": "0x52ee4d3cba58d1a0462bbfb6813bf5aa1b35078c3b859cded2b727c1d9469ea", + "method": "register_contract" }, { - "target": "0x70058e3886cb7411e8a77db90ee3dd453ac16b763b30bd99b3c8440fe42056e", - "method": "revoke_owner" + "target": "0x52ee4d3cba58d1a0462bbfb6813bf5aa1b35078c3b859cded2b727c1d9469ea", + "method": "init_contract" }, { - "target": "0x70058e3886cb7411e8a77db90ee3dd453ac16b763b30bd99b3c8440fe42056e", - "method": "grant_writer" + "target": "0x52ee4d3cba58d1a0462bbfb6813bf5aa1b35078c3b859cded2b727c1d9469ea", + "method": "upgrade_event" }, { - "target": "0x70058e3886cb7411e8a77db90ee3dd453ac16b763b30bd99b3c8440fe42056e", - "method": "revoke_writer" + "target": "0x52ee4d3cba58d1a0462bbfb6813bf5aa1b35078c3b859cded2b727c1d9469ea", + "method": "upgrade_model" }, { - "target": "0x70058e3886cb7411e8a77db90ee3dd453ac16b763b30bd99b3c8440fe42056e", - "method": "upgrade" + "target": "0x52ee4d3cba58d1a0462bbfb6813bf5aa1b35078c3b859cded2b727c1d9469ea", + "method": "upgrade_contract" }, { - "target": "0x7e5b903baa82407f74bd0a573352e0ca33f2b9fac3bcc2e35230119841cd08", - "method": "upgrade" + "target": "0x52ee4d3cba58d1a0462bbfb6813bf5aa1b35078c3b859cded2b727c1d9469ea", + "method": "emit_event" }, { - "target": "0x32bc6ef441c52acfc62ee1186885760803d88e165119fdad6e16f9424547981", - "method": "upgrade" + "target": "0x52ee4d3cba58d1a0462bbfb6813bf5aa1b35078c3b859cded2b727c1d9469ea", + "method": "emit_events" }, { - "target": "0x72b95d835f6158dd1454c1b4463a91222c6088f1bf5dd2cdd09268f92dad16f", - "method": "upgrade" + "target": "0x52ee4d3cba58d1a0462bbfb6813bf5aa1b35078c3b859cded2b727c1d9469ea", + "method": "set_entity" }, { - "target": "0x1cc5853cbf65c96d7c3c8f951e6315f3c1f19a34ace7b579da9ba87b974dce3", - "method": "spawn" + "target": "0x52ee4d3cba58d1a0462bbfb6813bf5aa1b35078c3b859cded2b727c1d9469ea", + "method": "set_entities" }, { - "target": "0x1cc5853cbf65c96d7c3c8f951e6315f3c1f19a34ace7b579da9ba87b974dce3", - "method": "move" + "target": "0x52ee4d3cba58d1a0462bbfb6813bf5aa1b35078c3b859cded2b727c1d9469ea", + "method": "delete_entity" }, { - "target": "0x1cc5853cbf65c96d7c3c8f951e6315f3c1f19a34ace7b579da9ba87b974dce3", - "method": "set_player_config" + "target": "0x52ee4d3cba58d1a0462bbfb6813bf5aa1b35078c3b859cded2b727c1d9469ea", + "method": "delete_entities" }, { - "target": "0x1cc5853cbf65c96d7c3c8f951e6315f3c1f19a34ace7b579da9ba87b974dce3", - "method": "reset_player_config" + "target": "0x52ee4d3cba58d1a0462bbfb6813bf5aa1b35078c3b859cded2b727c1d9469ea", + "method": "grant_owner" }, { - "target": "0x1cc5853cbf65c96d7c3c8f951e6315f3c1f19a34ace7b579da9ba87b974dce3", - "method": "set_player_server_profile" + "target": "0x52ee4d3cba58d1a0462bbfb6813bf5aa1b35078c3b859cded2b727c1d9469ea", + "method": "revoke_owner" }, { - "target": "0x1cc5853cbf65c96d7c3c8f951e6315f3c1f19a34ace7b579da9ba87b974dce3", - "method": "set_models" + "target": "0x52ee4d3cba58d1a0462bbfb6813bf5aa1b35078c3b859cded2b727c1d9469ea", + "method": "grant_writer" }, { - "target": "0x1cc5853cbf65c96d7c3c8f951e6315f3c1f19a34ace7b579da9ba87b974dce3", - "method": "enter_dungeon" + "target": "0x52ee4d3cba58d1a0462bbfb6813bf5aa1b35078c3b859cded2b727c1d9469ea", + "method": "revoke_writer" }, { - "target": "0x1cc5853cbf65c96d7c3c8f951e6315f3c1f19a34ace7b579da9ba87b974dce3", + "target": "0x52ee4d3cba58d1a0462bbfb6813bf5aa1b35078c3b859cded2b727c1d9469ea", "method": "upgrade" }, { diff --git a/crates/dojo-world/Cargo.toml b/crates/dojo-world/Cargo.toml index 39301853ca..204edb0462 100644 --- a/crates/dojo-world/Cargo.toml +++ b/crates/dojo-world/Cargo.toml @@ -51,5 +51,5 @@ tokio.workspace = true [features] contracts = [ "dep:dojo-types", "dep:http", "dep:num-traits" ] manifest = [ "contracts", "dep:dojo-types", "dep:scarb", "dep:url" ] -metadata = [ "dep:ipfs-api-backend-hyper", "dep:scarb", "dep:url" ] +ipfs = [ "dep:ipfs-api-backend-hyper", "dep:scarb", "dep:url" ] migration = [ "dep:dojo-utils", "dep:scarb", "dep:tokio", "manifest" ] diff --git a/crates/dojo/core-cairo-test/src/tests/world/metadata.cairo b/crates/dojo/core-cairo-test/src/tests/world/metadata.cairo index cce794c8b1..0471b82d62 100644 --- a/crates/dojo/core-cairo-test/src/tests/world/metadata.cairo +++ b/crates/dojo/core-cairo-test/src/tests/world/metadata.cairo @@ -9,7 +9,7 @@ fn test_set_metadata_world() { let world = world.dispatcher; let metadata = ResourceMetadata { - resource_id: 0, metadata_uri: format!("ipfs:world_with_a_long_uri_that") + resource_id: 0, metadata_uri: format!("ipfs:world_with_a_long_uri_that"), metadata_hash: 42 }; world.set_metadata(metadata.clone()); @@ -30,7 +30,7 @@ fn test_set_metadata_resource_owner() { starknet::testing::set_contract_address(bob); let metadata = ResourceMetadata { - resource_id: model_selector, metadata_uri: format!("ipfs:bob") + resource_id: model_selector, metadata_uri: format!("ipfs:bob"), metadata_hash: 42 }; drop_all_events(world.contract_address); @@ -46,6 +46,7 @@ fn test_set_metadata_resource_owner() { if let world::Event::MetadataUpdate(event) = event.unwrap() { assert(event.resource == metadata.resource_id, 'bad resource'); assert(event.uri == metadata.metadata_uri, 'bad uri'); + assert(event.hash == metadata.metadata_hash, 'bad hash'); } else { core::panic_with_felt252('no EventUpgraded event'); } @@ -70,7 +71,7 @@ fn test_set_metadata_not_possible_for_resource_writer() { starknet::testing::set_contract_address(bob); let metadata = ResourceMetadata { - resource_id: model_selector, metadata_uri: format!("ipfs:bob") + resource_id: model_selector, metadata_uri: format!("ipfs:bob"), metadata_hash: 42 }; world.set_metadata(metadata.clone()); @@ -85,7 +86,7 @@ fn test_set_metadata_not_possible_for_random_account() { let world = world.dispatcher; let metadata = ResourceMetadata { // World metadata. - resource_id: 0, metadata_uri: format!("ipfs:bob"), + resource_id: 0, metadata_uri: format!("ipfs:bob"), metadata_hash: 42 }; let bob = starknet::contract_address_const::<0xb0b>(); @@ -112,7 +113,7 @@ fn test_set_metadata_through_malicious_contract() { starknet::testing::set_contract_address(malicious_contract); let metadata = ResourceMetadata { - resource_id: model_selector, metadata_uri: format!("ipfs:bob") + resource_id: model_selector, metadata_uri: format!("ipfs:bob"), metadata_hash: 42 }; world.set_metadata(metadata.clone()); diff --git a/crates/dojo/core/src/model/metadata.cairo b/crates/dojo/core/src/model/metadata.cairo index 512d4c14c0..acef47be19 100644 --- a/crates/dojo/core/src/model/metadata.cairo +++ b/crates/dojo/core/src/model/metadata.cairo @@ -9,6 +9,7 @@ pub struct ResourceMetadata { #[key] pub resource_id: felt252, pub metadata_uri: ByteArray, + pub metadata_hash: felt252 } pub fn default_address() -> starknet::ContractAddress { diff --git a/crates/dojo/core/src/world/world_contract.cairo b/crates/dojo/core/src/world/world_contract.cairo index 30c513bcd6..ea1a10e1ea 100644 --- a/crates/dojo/core/src/world/world_contract.cairo +++ b/crates/dojo/core/src/world/world_contract.cairo @@ -111,7 +111,8 @@ pub mod world { pub struct MetadataUpdate { #[key] pub resource: felt252, - pub uri: ByteArray + pub uri: ByteArray, + pub hash: felt252 } #[derive(Drop, starknet::Event)] @@ -356,7 +357,11 @@ pub mod world { self .emit( - MetadataUpdate { resource: metadata.resource_id, uri: metadata.metadata_uri } + MetadataUpdate { + resource: metadata.resource_id, + uri: metadata.metadata_uri, + hash: metadata.metadata_hash + } ); } diff --git a/crates/dojo/utils/src/env.rs b/crates/dojo/utils/src/env.rs index 3229129dfb..ec4ed6e5b8 100644 --- a/crates/dojo/utils/src/env.rs +++ b/crates/dojo/utils/src/env.rs @@ -4,3 +4,6 @@ pub const DOJO_KEYSTORE_PATH_ENV_VAR: &str = "DOJO_KEYSTORE_PATH"; pub const DOJO_KEYSTORE_PASSWORD_ENV_VAR: &str = "DOJO_KEYSTORE_PASSWORD"; pub const DOJO_ACCOUNT_ADDRESS_ENV_VAR: &str = "DOJO_ACCOUNT_ADDRESS"; pub const DOJO_WORLD_ADDRESS_ENV_VAR: &str = "DOJO_WORLD_ADDRESS"; +pub const IPFS_URL_ENV_VAR: &str = "DOJO_IPFS_URL"; +pub const IPFS_USERNAME_ENV_VAR: &str = "DOJO_IPFS_USERNAME"; +pub const IPFS_PASSWORD_ENV_VAR: &str = "DOJO_IPFS_PASSWORD"; diff --git a/crates/dojo/world/Cargo.toml b/crates/dojo/world/Cargo.toml index c3cd9a6d44..00154e7671 100644 --- a/crates/dojo/world/Cargo.toml +++ b/crates/dojo/world/Cargo.toml @@ -30,6 +30,7 @@ num-bigint.workspace = true [dev-dependencies] tokio.workspace = true +futures.workspace = true [features] -metadata = [ "dep:ipfs-api-backend-hyper" ] +ipfs = [ "dep:ipfs-api-backend-hyper" ] diff --git a/crates/dojo/world/src/config/environment.rs b/crates/dojo/world/src/config/environment.rs index 9387bf2799..bf18602fe1 100644 --- a/crates/dojo/world/src/config/environment.rs +++ b/crates/dojo/world/src/config/environment.rs @@ -1,5 +1,7 @@ use serde::Deserialize; +use super::IpfsConfig; + #[derive(Default, Deserialize, Clone, Debug)] pub struct Environment { pub rpc_url: Option, @@ -10,6 +12,7 @@ pub struct Environment { pub world_address: Option, pub world_block: Option, pub http_headers: Option>, + pub ipfs_config: Option, } #[derive(Debug, Clone, Deserialize)] diff --git a/crates/dojo/world/src/config/ipfs_config.rs b/crates/dojo/world/src/config/ipfs_config.rs new file mode 100644 index 0000000000..ae55ac2c2a --- /dev/null +++ b/crates/dojo/world/src/config/ipfs_config.rs @@ -0,0 +1,22 @@ +use anyhow::Result; +use serde::Deserialize; + +#[derive(Default, Deserialize, Clone, Debug)] +pub struct IpfsConfig { + pub url: String, + pub username: String, + pub password: String, +} + +impl IpfsConfig { + pub fn assert_valid(&self) -> Result<()> { + if self.url.is_empty() || self.username.is_empty() || self.password.is_empty() { + anyhow::bail!("Invalid IPFS credentials: empty values not allowed"); + } + if !self.url.starts_with("http://") && !self.url.starts_with("https://") { + anyhow::bail!("Invalid IPFS URL: must start with http:// or https://"); + } + + Ok(()) + } +} diff --git a/crates/dojo/world/src/config/metadata_config.rs b/crates/dojo/world/src/config/metadata_config.rs index f1409df0ab..43ed71a973 100644 --- a/crates/dojo/world/src/config/metadata_config.rs +++ b/crates/dojo/world/src/config/metadata_config.rs @@ -1,11 +1,13 @@ //! Metadata configuration for the world. use std::collections::HashMap; +use std::hash::{Hash, Hasher}; use serde::{Deserialize, Serialize}; +use serde_json::json; use url::Url; -use crate::config::WorldConfig; +use crate::config::{ResourceConfig, WorldConfig}; use crate::uri::Uri; /// World metadata that describes the world. @@ -33,3 +35,62 @@ impl From for WorldMetadata { } } } + +impl Hash for WorldMetadata { + fn hash(&self, state: &mut H) { + self.name.hash(state); + self.seed.hash(state); + self.description.hash(state); + self.cover_uri.hash(state); + self.icon_uri.hash(state); + self.website.hash(state); + + json!(self.socials).to_string().hash(state); + + // include icon and cover data into the hash to + // detect data changes even if the filename is the same. + if let Some(Uri::File(icon)) = &self.icon_uri { + let icon_data = std::fs::read(icon).expect("read icon failed"); + icon_data.hash(state); + }; + + if let Some(Uri::File(cover)) = &self.cover_uri { + let cover_data = std::fs::read(cover).expect("read cover failed"); + cover_data.hash(state); + }; + } +} + +/// resource metadata that describes world resources such as contracts, +/// models or events. +#[derive(Default, Serialize, Deserialize, Debug, Clone)] +pub struct ResourceMetadata { + pub name: String, + pub description: Option, + pub icon_uri: Option, +} + +impl From for ResourceMetadata { + fn from(config: ResourceConfig) -> Self { + ResourceMetadata { + name: dojo_types::naming::get_name_from_tag(&config.tag), + description: config.description, + icon_uri: config.icon_uri, + } + } +} + +impl Hash for ResourceMetadata { + fn hash(&self, state: &mut H) { + self.name.hash(state); + self.description.hash(state); + self.icon_uri.hash(state); + + // include icon and cover data into the hash to + // detect data changes even if the filename is the same. + if let Some(Uri::File(icon)) = &self.icon_uri { + let icon_data = std::fs::read(icon).expect("read icon failed"); + icon_data.hash(state); + }; + } +} diff --git a/crates/dojo/world/src/config/mod.rs b/crates/dojo/world/src/config/mod.rs index 35e0323de9..1bc23a8b66 100644 --- a/crates/dojo/world/src/config/mod.rs +++ b/crates/dojo/world/src/config/mod.rs @@ -1,13 +1,17 @@ pub mod calldata_decoder; pub mod environment; +pub mod ipfs_config; pub mod metadata_config; pub mod migration_config; pub mod namespace_config; pub mod profile_config; +pub mod resource_config; pub mod world_config; pub use environment::Environment; +pub use ipfs_config::IpfsConfig; pub use metadata_config::WorldMetadata; pub use namespace_config::NamespaceConfig; pub use profile_config::ProfileConfig; +pub use resource_config::ResourceConfig; pub use world_config::WorldConfig; diff --git a/crates/dojo/world/src/config/profile_config.rs b/crates/dojo/world/src/config/profile_config.rs index 4297a16d57..7bdf22f3ce 100644 --- a/crates/dojo/world/src/config/profile_config.rs +++ b/crates/dojo/world/src/config/profile_config.rs @@ -9,6 +9,7 @@ use toml; use super::environment::Environment; use super::migration_config::MigrationConfig; use super::namespace_config::NamespaceConfig; +use super::resource_config::ResourceConfig; use super::world_config::WorldConfig; /// Profile configuration that is used to configure the world and its environment. @@ -17,6 +18,9 @@ use super::world_config::WorldConfig; #[derive(Debug, Clone, Default, Deserialize)] pub struct ProfileConfig { pub world: WorldConfig, + pub models: Option>, + pub contracts: Option>, + pub events: Option>, pub namespace: NamespaceConfig, pub env: Option, pub migration: Option, @@ -134,6 +138,21 @@ mod tests { website = "https://example.com" socials = { "twitter" = "test", "discord" = "test" } + [[models]] + tag = "ns1-m1" + description = "This is the m1 model" + icon_uri = "ipfs://dojo/m1.png" + + [[contracts]] + tag = "ns1-c1" + description = "This is the c1 contract" + icon_uri = "ipfs://dojo/c1.png" + + [[events]] + tag = "ns1-e1" + description = "This is the e1 event" + icon_uri = "ipfs://dojo/e1.png" + [namespace] default = "test" mappings = { "test" = ["test2"] } @@ -146,6 +165,11 @@ mod tests { keystore_password = "test" world_address = "test" + [env.ipfs_config] + url = "https://ipfs.service" + username = "johndoe" + password = "123456" + [migration] skip_contracts = [ "module::my-contract" ] @@ -172,6 +196,11 @@ mod tests { assert_eq!(env.keystore_password, Some("test".to_string())); assert_eq!(env.world_address, Some("test".to_string())); + let ipfs_config = env.ipfs_config.unwrap(); + assert_eq!(ipfs_config.url, "https://ipfs.service".to_string()); + assert_eq!(ipfs_config.username, "johndoe".to_string()); + assert_eq!(ipfs_config.password, "123456".to_string()); + assert_eq!(config.world.description, Some("test".to_string())); assert_eq!( config.world.cover_uri, @@ -190,6 +219,27 @@ mod tests { ])) ); + assert!(config.models.is_some()); + let models = config.models.unwrap(); + assert_eq!(models.len(), 1); + assert_eq!(models[0].tag, "ns1-m1"); + assert_eq!(models[0].description, Some("This is the m1 model".to_string())); + assert_eq!(models[0].icon_uri, Some(Uri::from_string("ipfs://dojo/m1.png").unwrap())); + + assert!(config.contracts.is_some()); + let contracts = config.contracts.unwrap(); + assert_eq!(contracts.len(), 1); + assert_eq!(contracts[0].tag, "ns1-c1"); + assert_eq!(contracts[0].description, Some("This is the c1 contract".to_string())); + assert_eq!(contracts[0].icon_uri, Some(Uri::from_string("ipfs://dojo/c1.png").unwrap())); + + assert!(config.events.is_some()); + let events = config.events.unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].tag, "ns1-e1"); + assert_eq!(events[0].description, Some("This is the e1 event".to_string())); + assert_eq!(events[0].icon_uri, Some(Uri::from_string("ipfs://dojo/e1.png").unwrap())); + assert_eq!(config.namespace.default, "test".to_string()); assert_eq!( config.namespace.mappings, diff --git a/crates/dojo/world/src/config/resource_config.rs b/crates/dojo/world/src/config/resource_config.rs new file mode 100644 index 0000000000..ba0e12dcb1 --- /dev/null +++ b/crates/dojo/world/src/config/resource_config.rs @@ -0,0 +1,10 @@ +use serde::Deserialize; + +use crate::uri::Uri; + +#[derive(Debug, Clone, Deserialize)] +pub struct ResourceConfig { + pub tag: String, + pub description: Option, + pub icon_uri: Option, +} diff --git a/crates/dojo/world/src/constants.rs b/crates/dojo/world/src/constants.rs new file mode 100644 index 0000000000..9aa55c2b1f --- /dev/null +++ b/crates/dojo/world/src/constants.rs @@ -0,0 +1,4 @@ +use starknet_crypto::Felt; + +// the world selector +pub const WORLD: Felt = Felt::ZERO; diff --git a/crates/dojo/world/src/contracts/abigen/world.rs b/crates/dojo/world/src/contracts/abigen/world.rs index bd07917a9f..eec0cfc5bf 100644 --- a/crates/dojo/world/src/contracts/abigen/world.rs +++ b/crates/dojo/world/src/contracts/abigen/world.rs @@ -393,6 +393,7 @@ impl cainome::cairo_serde::CairoSerde for FieldLayout { pub struct MetadataUpdate { pub resource: starknet::core::types::Felt, pub uri: cainome::cairo_serde::ByteArray, + pub hash: starknet::core::types::Felt, } impl cainome::cairo_serde::CairoSerde for MetadataUpdate { type RustType = Self; @@ -402,12 +403,14 @@ impl cainome::cairo_serde::CairoSerde for MetadataUpdate { let mut __size = 0; __size += starknet::core::types::Felt::cairo_serialized_size(&__rust.resource); __size += cainome::cairo_serde::ByteArray::cairo_serialized_size(&__rust.uri); + __size += starknet::core::types::Felt::cairo_serialized_size(&__rust.hash); __size } fn cairo_serialize(__rust: &Self::RustType) -> Vec { let mut __out: Vec = vec![]; __out.extend(starknet::core::types::Felt::cairo_serialize(&__rust.resource)); __out.extend(cainome::cairo_serde::ByteArray::cairo_serialize(&__rust.uri)); + __out.extend(starknet::core::types::Felt::cairo_serialize(&__rust.hash)); __out } fn cairo_deserialize( @@ -419,7 +422,9 @@ impl cainome::cairo_serde::CairoSerde for MetadataUpdate { __offset += starknet::core::types::Felt::cairo_serialized_size(&resource); let uri = cainome::cairo_serde::ByteArray::cairo_deserialize(__felts, __offset)?; __offset += cainome::cairo_serde::ByteArray::cairo_serialized_size(&uri); - Ok(MetadataUpdate { resource, uri }) + let hash = starknet::core::types::Felt::cairo_deserialize(__felts, __offset)?; + __offset += starknet::core::types::Felt::cairo_serialized_size(&hash); + Ok(MetadataUpdate { resource, uri, hash }) } } impl MetadataUpdate { @@ -625,6 +630,7 @@ impl OwnerUpdated { pub struct ResourceMetadata { pub resource_id: starknet::core::types::Felt, pub metadata_uri: cainome::cairo_serde::ByteArray, + pub metadata_hash: starknet::core::types::Felt, } impl cainome::cairo_serde::CairoSerde for ResourceMetadata { type RustType = Self; @@ -634,12 +640,14 @@ impl cainome::cairo_serde::CairoSerde for ResourceMetadata { let mut __size = 0; __size += starknet::core::types::Felt::cairo_serialized_size(&__rust.resource_id); __size += cainome::cairo_serde::ByteArray::cairo_serialized_size(&__rust.metadata_uri); + __size += starknet::core::types::Felt::cairo_serialized_size(&__rust.metadata_hash); __size } fn cairo_serialize(__rust: &Self::RustType) -> Vec { let mut __out: Vec = vec![]; __out.extend(starknet::core::types::Felt::cairo_serialize(&__rust.resource_id)); __out.extend(cainome::cairo_serde::ByteArray::cairo_serialize(&__rust.metadata_uri)); + __out.extend(starknet::core::types::Felt::cairo_serialize(&__rust.metadata_hash)); __out } fn cairo_deserialize( @@ -651,7 +659,9 @@ impl cainome::cairo_serde::CairoSerde for ResourceMetadata { __offset += starknet::core::types::Felt::cairo_serialized_size(&resource_id); let metadata_uri = cainome::cairo_serde::ByteArray::cairo_deserialize(__felts, __offset)?; __offset += cainome::cairo_serde::ByteArray::cairo_serialized_size(&metadata_uri); - Ok(ResourceMetadata { resource_id, metadata_uri }) + let metadata_hash = starknet::core::types::Felt::cairo_deserialize(__felts, __offset)?; + __offset += starknet::core::types::Felt::cairo_serialized_size(&metadata_hash); + Ok(ResourceMetadata { resource_id, metadata_uri, metadata_hash }) } } #[derive(Clone, serde::Serialize, serde::Deserialize, PartialEq, Debug)] @@ -1791,7 +1801,18 @@ impl TryFrom<&starknet::core::types::EmittedEvent> for Event { } }; data_offset += cainome::cairo_serde::ByteArray::cairo_serialized_size(&uri); - return Ok(Event::MetadataUpdate(MetadataUpdate { resource, uri })); + let hash = + match starknet::core::types::Felt::cairo_deserialize(&event.data, data_offset) { + Ok(v) => v, + Err(e) => { + return Err(format!( + "Could not deserialize field {} for {}: {:?}", + "hash", "MetadataUpdate", e + )); + } + }; + data_offset += starknet::core::types::Felt::cairo_serialized_size(&hash); + return Ok(Event::MetadataUpdate(MetadataUpdate { resource, uri, hash })); } let selector = event.keys[0]; if selector @@ -2660,7 +2681,18 @@ impl TryFrom<&starknet::core::types::Event> for Event { } }; data_offset += cainome::cairo_serde::ByteArray::cairo_serialized_size(&uri); - return Ok(Event::MetadataUpdate(MetadataUpdate { resource, uri })); + let hash = + match starknet::core::types::Felt::cairo_deserialize(&event.data, data_offset) { + Ok(v) => v, + Err(e) => { + return Err(format!( + "Could not deserialize field {} for {}: {:?}", + "hash", "MetadataUpdate", e + )); + } + }; + data_offset += starknet::core::types::Felt::cairo_serialized_size(&hash); + return Ok(Event::MetadataUpdate(MetadataUpdate { resource, uri, hash })); } let selector = event.keys[0]; if selector diff --git a/crates/dojo/world/src/diff/compare.rs b/crates/dojo/world/src/diff/compare.rs index 53224ae5f0..ce2d3f0986 100644 --- a/crates/dojo/world/src/diff/compare.rs +++ b/crates/dojo/world/src/diff/compare.rs @@ -108,6 +108,7 @@ mod tests { address: Felt::ZERO, owners: HashSet::new(), writers: HashSet::new(), + metadata_hash: Felt::ZERO, }, }); @@ -143,6 +144,7 @@ mod tests { address: Felt::ZERO, owners: HashSet::new(), writers: HashSet::new(), + metadata_hash: Felt::ZERO, }, }); @@ -192,6 +194,7 @@ mod tests { address: Felt::ZERO, owners: HashSet::new(), writers: HashSet::new(), + metadata_hash: Felt::ZERO, }, is_initialized: true, }); diff --git a/crates/dojo/world/src/diff/mod.rs b/crates/dojo/world/src/diff/mod.rs index 799a18f19c..0bf084255e 100644 --- a/crates/dojo/world/src/diff/mod.rs +++ b/crates/dojo/world/src/diff/mod.rs @@ -29,6 +29,8 @@ pub use resource::*; pub struct WorldStatusInfo { /// The address of the world. pub address: Felt, + /// The hash of the metadata associated to the world. + pub metadata_hash: Felt, /// The class hash of the world. pub class_hash: Felt, /// The casm class hash of the world. @@ -77,6 +79,7 @@ impl WorldDiff { let mut diff = Self { world_info: WorldStatusInfo { address: local.deterministic_world_address()?, + metadata_hash: Felt::ZERO, class_hash: local.class_hash, casm_class_hash: local.casm_class_hash, class: local.class, @@ -117,6 +120,7 @@ impl WorldDiff { world_info: WorldStatusInfo { // As the remote world was found, its address is always used. address: remote.address, + metadata_hash: remote.metadata_hash, class_hash: local.class_hash, casm_class_hash: local.casm_class_hash, class: local.class, diff --git a/crates/dojo/world/src/diff/resource.rs b/crates/dojo/world/src/diff/resource.rs index 3a33f90fcd..a5adf7f9f2 100644 --- a/crates/dojo/world/src/diff/resource.rs +++ b/crates/dojo/world/src/diff/resource.rs @@ -113,6 +113,15 @@ impl ResourceDiff { } } + /// Returns the current metadata hash of the resource. + pub fn metadata_hash(&self) -> Felt { + match self { + ResourceDiff::Created(_) => Felt::ZERO, + ResourceDiff::Updated(_, remote) => remote.metadata_hash(), + ResourceDiff::Synced(_, remote) => remote.metadata_hash(), + } + } + pub fn abi(&self) -> Vec { match self { ResourceDiff::Created(local) => local.abi(), diff --git a/crates/dojo/world/src/lib.rs b/crates/dojo/world/src/lib.rs index 1b7dbae2d8..0c61344148 100644 --- a/crates/dojo/world/src/lib.rs +++ b/crates/dojo/world/src/lib.rs @@ -1,13 +1,14 @@ #![warn(unused_crate_dependencies)] -#[cfg(feature = "metadata")] pub mod metadata; pub mod config; +pub mod constants; pub mod contracts; pub mod diff; pub mod local; pub mod remote; +pub mod services; pub mod uri; pub mod utils; diff --git a/crates/dojo/world/src/metadata/metadata_storage.rs b/crates/dojo/world/src/metadata/metadata_storage.rs new file mode 100644 index 0000000000..fc7ec2a134 --- /dev/null +++ b/crates/dojo/world/src/metadata/metadata_storage.rs @@ -0,0 +1,116 @@ +use std::hash::{DefaultHasher, Hash, Hasher}; + +use anyhow::{Context, Result}; +use serde_json::json; +use starknet_crypto::Felt; + +use crate::config::metadata_config::{ResourceMetadata, WorldMetadata}; +use crate::services::UploadService; +use crate::uri::Uri; + +/// Helper function to compute metadata hash. +/// +/// # Arguments +/// * `data` - the data to hash. +/// +/// # Returns +/// The hash value. +fn compute_metadata_hash(data: T) -> u64 +where + T: Hash, +{ + let mut hasher = DefaultHasher::new(); + data.hash(&mut hasher); + hasher.finish() +} + +/// Helper function to process an optional URI. +/// +/// If the URI is set and refer to a local asset, this asset +/// is then uploaded using the provided UploadService. +/// In any other case, the URI is kept as it is. +/// +/// # Arguments +/// * `uri` - The URI to process +/// * `service` - The metadata service to use to upload assets. +/// +/// # Returns +/// The updated URI or a Anyhow error. +async fn upload_uri(uri: &Option, service: &mut impl UploadService) -> Result> { + if let Some(Uri::File(path)) = uri { + let data = std::fs::read(path)?; + let uploaded_uri = Uri::Ipfs(service.upload(data).await?); + Ok(Some(uploaded_uri)) + } else { + Ok(uri.clone()) + } +} + +/// Trait to be implemented by metadata structs to be +/// uploadable on a storage system. +#[allow(async_fn_in_trait)] +pub trait MetadataStorage { + /// Upload metadata using the provided service. + /// + /// # Arguments + /// * `service` - service to use to upload metadata + /// + /// # Returns + /// The uploaded metadata URI or a Anyhow error. + async fn upload(&self, service: &mut impl UploadService) -> Result; + + /// Upload metadata using the provided service, only if it has changed. + /// + /// # Arguments + /// * `service` - service to use to upload metadata + /// * `current_hash` - the hash of the previously uploaded metadata + /// + /// # Returns + /// The uploaded metadata URI or a Anyhow error. + async fn upload_if_changed( + &self, + service: &mut impl UploadService, + current_hash: Felt, + ) -> Result> + where + Self: std::hash::Hash, + { + let new_hash = compute_metadata_hash(self); + let new_hash = Felt::from_raw([0, 0, 0, new_hash]); + + if new_hash != current_hash { + let new_uri = self.upload(service).await?; + return Ok(Some((new_uri, new_hash))); + } + + Ok(None) + } +} + +#[allow(async_fn_in_trait)] +impl MetadataStorage for WorldMetadata { + async fn upload(&self, service: &mut impl UploadService) -> Result { + let mut meta = self.clone(); + + meta.icon_uri = + upload_uri(&self.icon_uri, service).await.context("Failed to upload icon URI")?; + meta.cover_uri = + upload_uri(&self.cover_uri, service).await.context("Failed to upload cover URI")?; + + let serialized = json!(meta).to_string(); + service.upload(serialized.as_bytes().to_vec()).await.context("Failed to upload metadata") + } +} + +#[allow(async_fn_in_trait)] +impl MetadataStorage for ResourceMetadata { + async fn upload(&self, service: &mut impl UploadService) -> Result { + let mut meta = self.clone(); + + meta.icon_uri = + upload_uri(&self.icon_uri, service).await.context("Failed to upload icon URI")?; + + let serialized = json!(meta).to_string(); + service.upload(serialized.as_bytes().to_vec()).await.context("Failed to upload metadata") + } +} diff --git a/crates/dojo/world/src/metadata/metadata_test.rs b/crates/dojo/world/src/metadata/metadata_test.rs index cfedcfe191..a324fd45a6 100644 --- a/crates/dojo/world/src/metadata/metadata_test.rs +++ b/crates/dojo/world/src/metadata/metadata_test.rs @@ -1,34 +1,194 @@ -// use std::collections::HashMap; -// use std::fs; -// -// use camino::Utf8PathBuf; -// use dojo_test_utils::compiler; -// use scarb::compiler::Profile; -// use scarb::ops; -// use url::Url; -// -// use crate::contracts::naming::{get_filename_from_tag, TAG_SEPARATOR}; -// use crate::manifest::{CONTRACTS_DIR, MODELS_DIR, WORLD_CONTRACT_TAG}; -// use crate::metadata::{ -// dojo_metadata_from_workspace, ArtifactMetadata, Uri, WorldMetadata, ABIS_DIR, BASE_DIR, -// MANIFESTS_DIR, -// }; -// -// #[tokio::test] -// async fn world_metadata_hash_and_upload() { -// let meta = WorldMetadata { -// name: "Test World".to_string(), -// seed: String::from("dojo_examples"), -// description: Some("A world used for testing".to_string()), -// cover_uri: Some(Uri::File("src/metadata/metadata_test_data/cover.png".into())), -// icon_uri: Some(Uri::File("src/metadata/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(); -// } +use core::str; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::str::FromStr; + +use starknet_crypto::Felt; +use url::Url; + +use super::metadata_storage::MetadataStorage; +use crate::config::metadata_config::{ResourceMetadata, WorldMetadata}; +use crate::services::{MockUploadService, UploadService}; +use crate::uri::Uri; + +/// Helper function to create a local file absolute path +/// from a relative path. +fn test_file_path(filename: &str) -> PathBuf { + fs::canonicalize( + PathBuf::from_str(&format!("./src/metadata/metadata_test_data/{}", filename)).unwrap(), + ) + .unwrap() +} + +/// Helper function to build a WorldMetadata for tests. +fn build_world_metadata() -> WorldMetadata { + WorldMetadata { + name: "world".to_string(), + seed: "world seed".to_string(), + description: Some("world description".to_string()), + cover_uri: Some(Uri::File(test_file_path("cover.png"))), + icon_uri: Some(Uri::File(test_file_path("icon.png"))), + website: Some(Url::parse("https://my_world.com").expect("parsing failed")), + socials: Some(HashMap::from([ + ("twitter".to_string(), "twitter_url".to_string()), + ("discord".to_string(), "discord_url".to_string()), + ])), + } +} + +/// Helper function to build a ResourceMetadata for tests. +fn build_resource_metadata() -> ResourceMetadata { + ResourceMetadata { + name: "my model".to_string(), + description: Some("my model description".to_string()), + icon_uri: Some(Uri::File(test_file_path("icon.png"))), + } +} + +// Helper function to check IPFS URI. +fn assert_ipfs_uri(uri: &Option) { + if let Some(uri) = uri { + assert!(uri.to_string().starts_with("ipfs://")); + } +} + +// Helper function to check IPFS content. +async fn assert_ipfs_content(service: &MockUploadService, uri: String, path: PathBuf) { + let ipfs_data = service.get(uri).await.expect("read metadata failed"); + let expected_data = std::fs::read(path).expect("read local data failed"); + assert_eq!(ipfs_data, expected_data); +} + +#[tokio::test] +async fn test_world_metadata() { + let mut metadata_service = MockUploadService::default(); + + let world_metadata = build_world_metadata(); + + // first metadata upload without existing hash. + let res = world_metadata.upload_if_changed(&mut metadata_service, Felt::ZERO).await; + + let (current_uri, current_hash) = if let Ok(Some(res)) = res { + res + } else { + panic!("Upload failed"); + }; + + // no change => the upload is not done. + let res = world_metadata.upload_if_changed(&mut metadata_service, current_hash).await; + + assert!(res.is_ok()); + assert!(res.unwrap().is_none()); + + // different hash => metadata are reuploaded. + let res = + world_metadata.upload_if_changed(&mut metadata_service, current_hash + Felt::ONE).await; + + let (new_uri, new_hash) = if let Ok(Some(res)) = res { + res + } else { + panic!("Upload failed"); + }; + + assert_eq!(new_uri, current_uri); + assert_eq!(new_hash, current_hash); + + // read back the metadata from service to be sure it is correctly written + let read_metadata = metadata_service.get(current_uri).await.expect("read metadata failed"); + + let read_metadata = str::from_utf8(&read_metadata); + assert!(read_metadata.is_ok()); + + let read_metadata = serde_json::from_str::(read_metadata.unwrap()); + assert!(read_metadata.is_ok()); + + let read_metadata = read_metadata.unwrap(); + + assert_eq!(read_metadata.name, "world".to_string()); + assert_eq!(read_metadata.seed, "world seed".to_string()); + assert_eq!(read_metadata.description, Some("world description".to_string())); + assert_eq!(read_metadata.website, Some(Url::parse("https://my_world.com").unwrap())); + assert_eq!( + read_metadata.socials, + Some(HashMap::from([ + ("twitter".to_string(), "twitter_url".to_string()), + ("discord".to_string(), "discord_url".to_string()), + ])) + ); + + assert_ipfs_uri(&read_metadata.cover_uri); + assert_ipfs_content( + &metadata_service, + read_metadata.cover_uri.unwrap().to_string(), + fs::canonicalize(PathBuf::from_str("./src/metadata/metadata_test_data/cover.png").unwrap()) + .unwrap(), + ) + .await; + + assert_ipfs_uri(&read_metadata.icon_uri); + assert_ipfs_content( + &metadata_service, + read_metadata.icon_uri.unwrap().to_string(), + fs::canonicalize(PathBuf::from_str("./src/metadata/metadata_test_data/icon.png").unwrap()) + .unwrap(), + ) + .await; +} + +#[tokio::test] +async fn test_resource_metadata() { + let mut metadata_service = MockUploadService::default(); + + let resource_metadata = build_resource_metadata(); + + // first metadata upload without existing hash. + let res = resource_metadata.upload_if_changed(&mut metadata_service, Felt::ZERO).await; + assert!(res.is_ok()); + let res = res.unwrap(); + + assert!(res.is_some()); + let (current_uri, current_hash) = res.unwrap(); + + // no change => the upload is not done. + let res = resource_metadata.upload_if_changed(&mut metadata_service, current_hash).await; + assert!(res.is_ok()); + let res = res.unwrap(); + + assert!(res.is_none()); + + // different hash => metadata are reuploaded. + let res = + resource_metadata.upload_if_changed(&mut metadata_service, current_hash + Felt::ONE).await; + assert!(res.is_ok()); + let res = res.unwrap(); + + assert!(res.is_some()); + let (new_uri, new_hash) = res.unwrap(); + + assert_eq!(new_uri, current_uri); + assert_eq!(new_hash, current_hash); + + // read back the metadata stored on IPFS to be sure it is correctly written + let read_metadata = metadata_service.get(current_uri).await.expect("read metadata failed"); + + let read_metadata = str::from_utf8(&read_metadata); + assert!(read_metadata.is_ok()); + + let read_metadata = serde_json::from_str::(read_metadata.unwrap()); + assert!(read_metadata.is_ok()); + + let read_metadata = read_metadata.unwrap(); + + assert_eq!(read_metadata.name, "my model".to_string()); + assert_eq!(read_metadata.description, Some("my model description".to_string())); + + assert_ipfs_uri(&read_metadata.icon_uri); + assert_ipfs_content( + &metadata_service, + read_metadata.icon_uri.unwrap().to_string(), + fs::canonicalize(PathBuf::from_str("./src/metadata/metadata_test_data/icon.png").unwrap()) + .unwrap(), + ) + .await; +} diff --git a/crates/dojo/world/src/metadata/metadata_test_data/abi.json b/crates/dojo/world/src/metadata/metadata_test_data/abi.json deleted file mode 100644 index 78efed0140..0000000000 --- a/crates/dojo/world/src/metadata/metadata_test_data/abi.json +++ /dev/null @@ -1,17 +0,0 @@ -[ - { - "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/metadata_test_data/icon.png b/crates/dojo/world/src/metadata/metadata_test_data/icon.png new file mode 100644 index 0000000000..45ad927b14 Binary files /dev/null and b/crates/dojo/world/src/metadata/metadata_test_data/icon.png differ diff --git a/crates/dojo/world/src/metadata/metadata_test_data/source.cairo b/crates/dojo/world/src/metadata/metadata_test_data/source.cairo deleted file mode 100644 index c917342ece..0000000000 --- a/crates/dojo/world/src/metadata/metadata_test_data/source.cairo +++ /dev/null @@ -1,79 +0,0 @@ -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/metadata/mod.rs b/crates/dojo/world/src/metadata/mod.rs index 16a61c9701..cd80aa8877 100644 --- a/crates/dojo/world/src/metadata/mod.rs +++ b/crates/dojo/world/src/metadata/mod.rs @@ -1,3 +1,5 @@ -//! Metadata for the world. +pub mod metadata_storage; +pub use metadata_storage::MetadataStorage; -pub mod world; +#[cfg(test)] +mod metadata_test; diff --git a/crates/dojo/world/src/metadata/world.rs b/crates/dojo/world/src/metadata/world.rs deleted file mode 100644 index be76c31f6a..0000000000 --- a/crates/dojo/world/src/metadata/world.rs +++ /dev/null @@ -1,44 +0,0 @@ -use std::io::Cursor; - -use anyhow::Result; -use ipfs_api_backend_hyper::{IpfsApi, IpfsClient, TryFromUri}; -use serde_json::json; - -use crate::config::metadata_config::WorldMetadata; -use crate::uri::Uri; - -#[cfg(test)] -#[path = "metadata_test.rs"] -mod test; - -pub const IPFS_CLIENT_URL: &str = "https://ipfs.infura.io:5001"; -pub const IPFS_USERNAME: &str = "2EBrzr7ZASQZKH32sl2xWauXPSA"; -pub const IPFS_PASSWORD: &str = "12290b883db9138a8ae3363b6739d220"; - -impl WorldMetadata { - 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(icon)) = &self.icon_uri { - let icon_data = std::fs::read(icon)?; - let reader = Cursor::new(icon_data); - let response = client.add(reader).await?; - meta.icon_uri = Some(Uri::Ipfs(format!("ipfs://{}", response.hash))) - }; - - if let Some(Uri::File(cover)) = &self.cover_uri { - let cover_data = std::fs::read(cover)?; - let reader = Cursor::new(cover_data); - let response = client.add(reader).await?; - meta.cover_uri = 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) - } -} diff --git a/crates/dojo/world/src/remote/events_to_remote.rs b/crates/dojo/world/src/remote/events_to_remote.rs index a6404d61a0..f8a9a998f7 100644 --- a/crates/dojo/world/src/remote/events_to_remote.rs +++ b/crates/dojo/world/src/remote/events_to_remote.rs @@ -15,6 +15,7 @@ use tracing::trace; use super::permissions::PermissionsUpdateable; use super::{ResourceRemote, WorldRemote}; +use crate::constants::WORLD; use crate::contracts::abigen::world::{self, Event as WorldEvent}; use crate::remote::{CommonRemoteInfo, ContractRemote, EventRemote, ModelRemote, NamespaceRemote}; @@ -55,6 +56,7 @@ impl WorldRemote { world::ContractInitialized::event_selector(), world::WriterUpdated::event_selector(), world::OwnerUpdated::event_selector(), + world::MetadataUpdate::event_selector(), ]]; let filter = EventFilter { @@ -126,7 +128,7 @@ impl WorldRemote { self.class_hashes.push(e.class_hash.into()); // The creator is the world's owner, but no event emitted for that. - self.external_owners.insert(Felt::ZERO, HashSet::from([e.creator.into()])); + self.external_owners.insert(WORLD, HashSet::from([e.creator.into()])); trace!( class_hash = format!("{:#066x}", e.class_hash.0), @@ -250,6 +252,18 @@ impl WorldRemote { trace!(?e, "Owner updated."); } + WorldEvent::MetadataUpdate(e) => { + dbg!("metadata", &e); + if e.resource == WORLD { + self.metadata_hash = e.hash; + } else { + // Unwrap is safe because the resource must exist in the world. + let resource = self.resources.get_mut(&e.resource).unwrap(); + trace!(?resource, "Metadata updated."); + + resource.set_metadata_hash(e.hash); + } + } _ => { // Ignore events filtered out by the event filter. } @@ -516,4 +530,37 @@ mod tests { let resource = world_remote.resources.get(&selector).unwrap(); assert_eq!(resource.as_namespace_or_panic().owners, HashSet::from([])); } + + #[tokio::test] + async fn test_metadata_updated_event() { + let mut world_remote = WorldRemote::default(); + let selector = naming::compute_selector_from_names("ns", "m1"); + + let resource = ResourceRemote::Model(ModelRemote { + common: CommonRemoteInfo::new(Felt::TWO, "ns", "m1", Felt::ONE), + }); + world_remote.add_resource(resource); + + let event = WorldEvent::MetadataUpdate(world::MetadataUpdate { + resource: selector, + uri: ByteArray::from_string("ipfs://m1").unwrap(), + hash: Felt::THREE, + }); + + world_remote.match_event(event).unwrap(); + + let resource = world_remote.resources.get(&selector).unwrap(); + assert_eq!(resource.metadata_hash(), Felt::THREE); + + let event = WorldEvent::MetadataUpdate(world::MetadataUpdate { + resource: selector, + uri: ByteArray::from_string("ipfs://m1").unwrap(), + hash: Felt::ONE, + }); + + world_remote.match_event(event).unwrap(); + + let resource = world_remote.resources.get(&selector).unwrap(); + assert_eq!(resource.metadata_hash(), Felt::ONE); + } } diff --git a/crates/dojo/world/src/remote/mod.rs b/crates/dojo/world/src/remote/mod.rs index 8a6dc15405..b76e5ba3f4 100644 --- a/crates/dojo/world/src/remote/mod.rs +++ b/crates/dojo/world/src/remote/mod.rs @@ -23,6 +23,8 @@ use crate::{ContractAddress, DojoSelector}; pub struct WorldRemote { /// The world's address used to build the remote world. pub address: Felt, + /// The hash of the metadata associated to the world. + pub metadata_hash: Felt, /// The class hashes of the world. pub class_hashes: Vec, /// The resources of the world, by dojo selector. diff --git a/crates/dojo/world/src/remote/resource.rs b/crates/dojo/world/src/remote/resource.rs index b44e217db5..9a8c584079 100644 --- a/crates/dojo/world/src/remote/resource.rs +++ b/crates/dojo/world/src/remote/resource.rs @@ -30,6 +30,8 @@ pub struct CommonRemoteInfo { pub namespace: String, /// The address of the resource. pub address: ContractAddress, + /// The hash of the stored metadata associated to the resource if any. + pub metadata_hash: Felt, /// The contract addresses that have owner permission on the resource. pub owners: HashSet, /// The contract addresses that have writer permission on the resource. @@ -80,6 +82,7 @@ impl CommonRemoteInfo { name: name.to_string(), namespace: namespace.to_string(), address, + metadata_hash: Felt::ZERO, owners: HashSet::new(), writers: HashSet::new(), } @@ -173,6 +176,26 @@ impl ResourceRemote { } } + /// Set the hash of the stored metadata associated to the resource. + pub fn set_metadata_hash(&mut self, hash: Felt) { + match self { + ResourceRemote::Contract(c) => c.common.metadata_hash = hash, + ResourceRemote::Model(m) => m.common.metadata_hash = hash, + ResourceRemote::Event(e) => e.common.metadata_hash = hash, + ResourceRemote::Namespace(_) => {} + } + } + + /// The hash of the stored metadata associated to the resource. + pub fn metadata_hash(&self) -> Felt { + match self { + ResourceRemote::Contract(c) => c.common.metadata_hash, + ResourceRemote::Model(m) => m.common.metadata_hash, + ResourceRemote::Event(e) => e.common.metadata_hash, + ResourceRemote::Namespace(_) => Felt::ZERO, + } + } + /// Push a new class hash to the resource meaning it has been upgraded. pub fn push_class_hash(&mut self, class_hash: Felt) { match self { diff --git a/crates/dojo/world/src/services/ipfs_service.rs b/crates/dojo/world/src/services/ipfs_service.rs new file mode 100644 index 0000000000..30f9fda3dc --- /dev/null +++ b/crates/dojo/world/src/services/ipfs_service.rs @@ -0,0 +1,65 @@ +use std::io::Cursor; + +use anyhow::Result; +#[cfg(test)] +use futures::TryStreamExt; +use ipfs_api_backend_hyper::{IpfsApi, TryFromUri}; + +use super::upload_service::UploadService; +use crate::config::IpfsConfig; + +/// IPFS implementation of UploadService, allowing to upload data to IPFS. +pub struct IpfsService { + client: ipfs_api_backend_hyper::IpfsClient, +} + +// impl required by clippy +impl std::fmt::Debug for IpfsService { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + write!(f, "IPFS service")?; + Ok(()) + } +} + +impl IpfsService { + /// Instanciate a new IPFS service with IPFS configuration. + /// + /// # Arguments + /// * `config` - The IPFS configuration + /// + /// # Returns + /// A new `IpfsService` is the IPFS client has been successfully + /// instanciated or a Anyhow error if not. + pub fn new(config: IpfsConfig) -> Result { + config.assert_valid()?; + + Ok(Self { + client: ipfs_api_backend_hyper::IpfsClient::from_str(&config.url)? + .with_credentials(config.username, &config.password), + }) + } +} + +#[allow(async_fn_in_trait)] +impl UploadService for IpfsService { + async fn upload(&mut self, data: Vec) -> Result { + let reader = Cursor::new(data); + let response = self + .client + .add(reader) + .await + .map_err(|e| anyhow::anyhow!("Failed to upload to IPFS: {}", e))?; + Ok(format!("ipfs://{}", response.hash)) + } + + #[cfg(test)] + async fn get(&self, uri: String) -> Result> { + let res = self + .client + .cat(&uri.replace("ipfs://", "")) + .map_ok(|chunk| chunk.to_vec()) + .try_concat() + .await?; + Ok(res) + } +} diff --git a/crates/dojo/world/src/services/mock_upload_service.rs b/crates/dojo/world/src/services/mock_upload_service.rs new file mode 100644 index 0000000000..4e04f7a2e0 --- /dev/null +++ b/crates/dojo/world/src/services/mock_upload_service.rs @@ -0,0 +1,39 @@ +use std::collections::HashMap; +use std::hash::{DefaultHasher, Hash, Hasher}; + +use anyhow::Result; + +use super::upload_service::UploadService; + +/// Mock implementation of UploadService to be used for tests only. +/// It just stores uri and data in a HashMap when `upload` is called, +/// and returns these data when `get` is called. +#[derive(Debug, Default)] +pub struct MockUploadService { + data: HashMap>, +} + +#[allow(async_fn_in_trait)] +impl UploadService for MockUploadService { + async fn upload(&mut self, data: Vec) -> Result { + let mut hasher = DefaultHasher::new(); + data.hash(&mut hasher); + let hash = hasher.finish(); + + let uri = format!("ipfs://{:x}", hash); + self.data.insert(uri.clone(), data); + + Ok(uri) + } + + #[cfg(test)] + async fn get(&self, uri: String) -> Result> { + if !uri.starts_with("ipfs://") { + return Err(anyhow::anyhow!("Invalid URI format. Expected ipfs:// prefix")); + } + self.data + .get(&uri) + .cloned() + .ok_or_else(|| anyhow::anyhow!("No data found for URI: {}", uri)) + } +} diff --git a/crates/dojo/world/src/services/mod.rs b/crates/dojo/world/src/services/mod.rs new file mode 100644 index 0000000000..0cacac303b --- /dev/null +++ b/crates/dojo/world/src/services/mod.rs @@ -0,0 +1,9 @@ +mod upload_service; +pub use upload_service::UploadService; +mod mock_upload_service; +pub use mock_upload_service::MockUploadService; + +#[cfg(feature = "ipfs")] +mod ipfs_service; +#[cfg(feature = "ipfs")] +pub use ipfs_service::IpfsService; diff --git a/crates/dojo/world/src/services/upload_service.rs b/crates/dojo/world/src/services/upload_service.rs new file mode 100644 index 0000000000..d1dcf630f1 --- /dev/null +++ b/crates/dojo/world/src/services/upload_service.rs @@ -0,0 +1,26 @@ +use anyhow::Result; + +/// UploadService trait to be implemented to upload +/// some data on a specific storage system. +#[allow(async_fn_in_trait)] +pub trait UploadService: std::marker::Send + std::marker::Sync + std::marker::Unpin { + /// Upload some bytes (`data`) to the storage system, + /// and get back a string URI. + /// + /// # Arguments + /// * `data` - bytes to upload + /// + /// # Returns + /// A string URI or a Anyhow error. + async fn upload(&mut self, data: Vec) -> Result; + + /// Read stored bytes from a URI. (for tests only) + /// + /// # Arguments + /// * `uri` - the URI of the data to read + /// + /// # Returns + /// the read bytes or a Anyhow error. + #[cfg(test)] + async fn get(&self, uri: String) -> Result>; +} diff --git a/crates/dojo/world/src/uri.rs b/crates/dojo/world/src/uri.rs index b83b358eb9..8a2a93db76 100644 --- a/crates/dojo/world/src/uri.rs +++ b/crates/dojo/world/src/uri.rs @@ -1,3 +1,4 @@ +use std::hash::{Hash, Hasher}; use std::path::PathBuf; use anyhow::Result; @@ -17,6 +18,22 @@ pub enum Uri { File(PathBuf), } +impl Hash for Uri { + fn hash(&self, state: &mut H) { + match self { + Uri::Http(url) => { + url.to_string().hash(state); + } + Uri::Ipfs(uri) => { + uri.to_string().hash(state); + } + Uri::File(path) => { + path.hash(state); + } + } + } +} + impl Serialize for Uri { fn serialize(&self, serializer: S) -> Result where @@ -40,6 +57,16 @@ impl<'de> Deserialize<'de> for Uri { } } +impl std::fmt::Display for Uri { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Uri::File(path) => write!(f, "{}", path.to_string_lossy()), + Uri::Http(url) => write!(f, "{}", url), + Uri::Ipfs(uri) => write!(f, "{}", uri), + } + } +} + impl Uri { pub fn cid(&self) -> Option<&str> { match self { diff --git a/crates/sozo/ops/Cargo.toml b/crates/sozo/ops/Cargo.toml index 54a6982f8f..66d1969b2d 100644 --- a/crates/sozo/ops/Cargo.toml +++ b/crates/sozo/ops/Cargo.toml @@ -13,7 +13,7 @@ colored.workspace = true colored_json.workspace = true dojo-types.workspace = true dojo-utils.workspace = true -dojo-world.workspace = true +dojo-world = { workspace = true, features = [ "ipfs" ] } futures.workspace = true num-traits.workspace = true serde.workspace = true @@ -33,7 +33,6 @@ katana-runner = { workspace = true, optional = true } [dev-dependencies] assert_fs.workspace = true dojo-test-utils = { workspace = true, features = [ "build-examples" ] } -ipfs-api-backend-hyper.workspace = true katana-runner.workspace = true scarb.workspace = true sozo-scarbext.workspace = true diff --git a/crates/sozo/ops/src/migrate/mod.rs b/crates/sozo/ops/src/migrate/mod.rs index a5b1ddb52b..e9a924e87e 100644 --- a/crates/sozo/ops/src/migrate/mod.rs +++ b/crates/sozo/ops/src/migrate/mod.rs @@ -20,14 +20,19 @@ use std::collections::HashMap; +use anyhow::anyhow; use cainome::cairo_serde::{ByteArray, ClassHash, ContractAddress}; use dojo_utils::{Declarer, Deployer, Invoker, LabeledClass, TransactionResult, TxnConfig}; use dojo_world::config::calldata_decoder::decode_calldata; -use dojo_world::config::ProfileConfig; +use dojo_world::config::{metadata_config, ProfileConfig, ResourceConfig, WorldMetadata}; +use dojo_world::constants::WORLD; +use dojo_world::contracts::abigen::world::ResourceMetadata; use dojo_world::contracts::WorldContract; use dojo_world::diff::{Manifest, ResourceDiff, WorldDiff, WorldStatus}; use dojo_world::local::ResourceLocal; +use dojo_world::metadata::MetadataStorage; use dojo_world::remote::ResourceRemote; +use dojo_world::services::UploadService; use dojo_world::{utils, ResourceType}; use starknet::accounts::{ConnectedAccount, SingleOwnerAccount}; use starknet::core::types::Call; @@ -103,6 +108,104 @@ where }) } + /// Upload resources metadata to IPFS and update the ResourceMetadata Dojo model. + /// + /// # Arguments + /// + /// # Returns + pub async fn upload_metadata( + &self, + ui: &mut MigrationUi, + service: &mut impl UploadService, + ) -> anyhow::Result<()> { + ui.update_text("Uploading metadata..."); + + let mut invoker = Invoker::new(&self.world.account, self.txn_config); + + // world + let current_hash = self.diff.world_info.metadata_hash; + let new_metadata = WorldMetadata::from(self.diff.profile_config.world.clone()); + + let res = new_metadata.upload_if_changed(service, current_hash).await?; + + if let Some((new_uri, new_hash)) = res { + trace!(new_uri, new_hash = format!("{:#066x}", new_hash), "World metadata updated."); + + invoker.add_call(self.world.set_metadata_getcall(&ResourceMetadata { + resource_id: WORLD, + metadata_uri: ByteArray::from_string(&new_uri)?, + metadata_hash: new_hash, + })); + } + + // contracts + if let Some(configs) = &self.diff.profile_config.contracts { + let calls = self.upload_metadata_from_resource_config(service, configs).await?; + invoker.extend_calls(calls); + } + + // models + if let Some(configs) = &self.diff.profile_config.models { + let calls = self.upload_metadata_from_resource_config(service, configs).await?; + invoker.extend_calls(calls); + } + + // events + if let Some(configs) = &self.diff.profile_config.events { + let calls = self.upload_metadata_from_resource_config(service, configs).await?; + invoker.extend_calls(calls); + } + + if self.do_multicall() { + ui.update_text_boxed(format!("Uploading {} metadata...", invoker.calls.len())); + invoker.multicall().await.map_err(|e| anyhow!(e.to_string()))?; + } else { + ui.update_text_boxed(format!( + "Uploading {} metadata (sequentially)...", + invoker.calls.len() + )); + invoker.invoke_all_sequentially().await.map_err(|e| anyhow!(e.to_string()))?; + } + + Ok(()) + } + + async fn upload_metadata_from_resource_config( + &self, + service: &mut impl UploadService, + config: &[ResourceConfig], + ) -> anyhow::Result> { + let mut calls = vec![]; + + for item in config { + let selector = dojo_types::naming::compute_selector_from_tag_or_name(&item.tag); + + let current_hash = + self.diff.resources.get(&selector).map_or(Felt::ZERO, |r| r.metadata_hash()); + + let new_metadata = metadata_config::ResourceMetadata::from(item.clone()); + + let res = new_metadata.upload_if_changed(service, current_hash).await?; + + if let Some((new_uri, new_hash)) = res { + trace!( + tag = item.tag, + new_uri, + new_hash = format!("{:#066x}", new_hash), + "Resource metadata updated." + ); + + calls.push(self.world.set_metadata_getcall(&ResourceMetadata { + resource_id: selector, + metadata_uri: ByteArray::from_string(&new_uri)?, + metadata_hash: new_hash, + })); + } + } + + Ok(calls) + } + /// Returns whether multicall should be used. By default, it is enabled. fn do_multicall(&self) -> bool { self.profile_config diff --git a/crates/sozo/ops/src/tests/migration.rs b/crates/sozo/ops/src/tests/migration.rs index 7cb45dbf0f..99e26736af 100644 --- a/crates/sozo/ops/src/tests/migration.rs +++ b/crates/sozo/ops/src/tests/migration.rs @@ -5,13 +5,16 @@ use anyhow::Result; use dojo_test_utils::compiler::CompilerTestSetup; use dojo_test_utils::migration::copy_spawn_and_move_db; use dojo_utils::TxnConfig; +use dojo_world::config::ResourceConfig; use dojo_world::contracts::WorldContract; use dojo_world::diff::WorldDiff; +use dojo_world::services::MockUploadService; use katana_runner::RunnerCtx; use scarb::compiler::Profile; use sozo_scarbext::WorkspaceExt; use starknet::providers::jsonrpc::HttpTransport; use starknet::providers::JsonRpcClient; +use starknet_crypto::Felt; use crate::migrate::{Migration, MigrationResult}; use crate::migration_ui::MigrationUi; @@ -37,7 +40,7 @@ async fn setup_migration( } /// Migrates the spawn-and-move project from the local environment. -async fn migrate_spawn_and_move(sequencer: &RunnerCtx) -> MigrationResult { +async fn migrate_spawn_and_move(sequencer: &RunnerCtx, with_metadata: bool) -> MigrationResult { let account = sequencer.account(0); let provider = Arc::new(JsonRpcClient::new(HttpTransport::new(sequencer.url()))); @@ -58,13 +61,20 @@ async fn migrate_spawn_and_move(sequencer: &RunnerCtx) -> MigrationResult { let mut ui = MigrationUi::new(None).with_silent(); - migration.migrate(&mut ui).await.expect("Migration spawn-and-move failed.") + let res = migration.migrate(&mut ui).await.expect("Migration spawn-and-move failed."); + + if with_metadata { + let mut service = MockUploadService::default(); + migration.upload_metadata(&mut ui, &mut service).await.expect("Upload metadata failed"); + } + + res } #[tokio::test(flavor = "multi_thread")] #[katana_runner::test(accounts = 10)] async fn migrate_from_local(sequencer: &RunnerCtx) { - let MigrationResult { manifest, has_changes } = migrate_spawn_and_move(sequencer).await; + let MigrationResult { manifest, has_changes } = migrate_spawn_and_move(sequencer, false).await; assert!(has_changes); assert_eq!(manifest.contracts.len(), 4); @@ -73,7 +83,70 @@ async fn migrate_from_local(sequencer: &RunnerCtx) { #[tokio::test(flavor = "multi_thread")] #[katana_runner::test(accounts = 10, db_dir = copy_spawn_and_move_db().as_str())] async fn migrate_no_change(sequencer: &RunnerCtx) { - let MigrationResult { manifest, has_changes } = migrate_spawn_and_move(sequencer).await; + let MigrationResult { manifest, has_changes } = migrate_spawn_and_move(sequencer, false).await; assert!(!has_changes); assert_eq!(manifest.contracts.len(), 4); } + +// helper to check metadata of a list of resources +fn check_resources( + diff: &WorldDiff, + resources: Option>, + expected_count: usize, + checker: &dyn Fn(Felt) -> bool, +) { + assert!(resources.is_some()); + let resources = resources.unwrap(); + + assert_eq!(resources.len(), expected_count); + + for resource in resources { + let selector = dojo_types::naming::compute_selector_from_tag_or_name(&resource.tag); + + let resource = diff.resources.get(&selector); + assert!(resource.is_some()); + + let resource = resource.unwrap(); + + assert!(checker(resource.metadata_hash()), "Bad resource hash: {}", resource.name()); + } +} + +#[tokio::test(flavor = "multi_thread")] +#[katana_runner::test(accounts = 10, db_dir = copy_spawn_and_move_db().as_str())] +async fn upload_metadata(sequencer: &RunnerCtx) { + let is_set = |hash| hash != Felt::ZERO; + let is_not_set = |hash: Felt| hash == Felt::ZERO; + + let provider = Arc::new(JsonRpcClient::new(HttpTransport::new(sequencer.url()))); + + // here, metadata should not be set + let world_diff = setup_migration("spawn-and-move", Profile::DEV, provider.clone()) + .await + .expect("Failed to setup migration"); + let profile_config = world_diff.profile_config.clone(); + + assert!(is_not_set(world_diff.world_info.metadata_hash)); + check_resources(&world_diff, profile_config.contracts, 1, &is_not_set); + check_resources(&world_diff, profile_config.models, 3, &is_not_set); + check_resources(&world_diff, profile_config.events, 1, &is_not_set); + + // no change is expected for the migration itself but metadata + // should be uploaded. + let _ = migrate_spawn_and_move(sequencer, true).await; + + // Note that IPFS upload is deeply tested in dojo-world metadata tests. + // Here we just check that, after migration, resources associated to + // metadata configured in dojo_dev.toml have been successfully updated + // in the `ResourceMetadata` model of the world. + let world_diff = setup_migration("spawn-and-move", Profile::DEV, provider) + .await + .expect("Failed to setup migration"); + let profile_config = world_diff.profile_config.clone(); + + // check world and resources metadata from computed WorldDiff + assert!(is_set(world_diff.world_info.metadata_hash)); + check_resources(&world_diff, profile_config.contracts, 1, &is_set); + check_resources(&world_diff, profile_config.models, 3, &is_set); + check_resources(&world_diff, profile_config.events, 1, &is_set); +} diff --git a/crates/torii/graphql/Cargo.toml b/crates/torii/graphql/Cargo.toml index e34e21f1ac..3d0d497f32 100644 --- a/crates/torii/graphql/Cargo.toml +++ b/crates/torii/graphql/Cargo.toml @@ -37,7 +37,7 @@ warp.workspace = true camino.workspace = true dojo-test-utils = { workspace = true, features = [ "build-examples" ] } dojo-utils.workspace = true -dojo-world = { workspace = true, features = [ "metadata" ] } +dojo-world = { workspace = true, features = [ "ipfs" ] } katana-runner.workspace = true scarb.workspace = true serial_test = "2.0.0" diff --git a/crates/torii/types-test/Scarb.lock b/crates/torii/types-test/Scarb.lock index a81e216e17..ce6dacb687 100644 --- a/crates/torii/types-test/Scarb.lock +++ b/crates/torii/types-test/Scarb.lock @@ -14,7 +14,7 @@ version = "2.8.4" [[package]] name = "types_test" -version = "1.0.1" +version = "1.0.2" dependencies = [ "dojo", ] diff --git a/examples/simple/Scarb.lock b/examples/simple/Scarb.lock index 83d7d63f53..3b902cb684 100644 --- a/examples/simple/Scarb.lock +++ b/examples/simple/Scarb.lock @@ -3,7 +3,7 @@ version = 1 [[package]] name = "dojo" -version = "1.0.1" +version = "1.0.2" dependencies = [ "dojo_plugin", ] diff --git a/examples/simple/manifest_dev.json b/examples/simple/manifest_dev.json index ec085d7fce..4cba9216c2 100644 --- a/examples/simple/manifest_dev.json +++ b/examples/simple/manifest_dev.json @@ -1,7 +1,7 @@ { "world": { - "class_hash": "0x79d9ce84b97bcc2a631996c3100d57966fc2f5b061fb1ec4dfd0040976bcac6", - "address": "0x1b2e50266b9b673eb82e68249a1babde860f6414737b4a36ff7b29411a64666", + "class_hash": "0x45575a88cc5cef1e444c77ce60b7b4c9e73a01cbbe20926d5a4c72a94011410", + "address": "0x64613f376f05242dfcc9fe360fa2ce1fdd6b00b1ce73dae2ea649ea118fd9be", "seed": "simple", "name": "simple", "entrypoints": [ @@ -92,6 +92,10 @@ { "name": "metadata_uri", "type": "core::byte_array::ByteArray" + }, + { + "name": "metadata_hash", + "type": "core::felt252" } ] }, @@ -1002,6 +1006,11 @@ "name": "uri", "type": "core::byte_array::ByteArray", "kind": "data" + }, + { + "name": "hash", + "type": "core::felt252", + "kind": "data" } ] }, @@ -1243,7 +1252,7 @@ }, "contracts": [ { - "address": "0x366a86098f39b0c3e026067c846367e83111c727b88fa036597120d154d44f5", + "address": "0x1958afba5b86ac51f48313047357981a59ac274170e353f79d84eedc89219e3", "class_hash": "0x340e197b0fac61961591acdd872a89b0cb862893272ab72455356f5534baa7e", "abi": [ { @@ -1501,7 +1510,7 @@ ] }, { - "address": "0x53fbb8640694994275d8a1b31ce290ec13e0dd433077775e185a9c31f054008", + "address": "0x5d9f3674dd96f173735f949d303abf107d1761b838a733703e95b31cc479270", "class_hash": "0x2a400df88b0add6c980d281dc96354a5cfc2b886547e9ed621b097e21842ee6", "abi": [ { @@ -1677,7 +1686,7 @@ ] }, { - "address": "0x40c69a07b5a9b64f581176511c8f8eac9008b48cb3e3c543eac2bf7466c57e3", + "address": "0x38b6e133a81a3ef41438752fbcf497ed74f284fe64a70e02652c3fb434d4e8", "class_hash": "0x7cc8d15e576873d544640f7fd124bd430bd19c0f31e203fb069b4fc2f5c0ab9", "abi": [ { @@ -1853,7 +1862,7 @@ ] }, { - "address": "0x1302b12ead2cdce8cb0a47f461ecd9d53629e62e8d38327f6452066298381b5", + "address": "0x47882727700801173a3ff731928c9d41303237e142104bd155f48fac3806814", "class_hash": "0x340e197b0fac61961591acdd872a89b0cb862893272ab72455356f5534baa7e", "abi": [ { diff --git a/examples/spawn-and-move/Scarb.lock b/examples/spawn-and-move/Scarb.lock index 23333572dd..7728cc5d67 100644 --- a/examples/spawn-and-move/Scarb.lock +++ b/examples/spawn-and-move/Scarb.lock @@ -31,7 +31,7 @@ dependencies = [ [[package]] name = "dojo_examples" -version = "1.0.1" +version = "1.0.2" dependencies = [ "armory", "bestiary", diff --git a/examples/spawn-and-move/dojo_dev.toml b/examples/spawn-and-move/dojo_dev.toml index 23441d37ae..82019f8868 100644 --- a/examples/spawn-and-move/dojo_dev.toml +++ b/examples/spawn-and-move/dojo_dev.toml @@ -3,6 +3,26 @@ description = "example world" name = "example" seed = "dojo_examples" +[[models]] +tag = "ns-Message" +description = "Message sent by a player" + +[[models]] +tag = "ns-Position" +description = "position of a player in the world" + +[[models]] +tag = "ns-Moves" +description = "move of a player in the world" + +[[events]] +tag = "ns-Moved" +description = "when a player has moved" + +[[contracts]] +tag = "ns-actions" +description = "set of actions for a player" + [namespace] default = "ns" @@ -11,7 +31,10 @@ rpc_url = "http://localhost:5050/" # Default account for katana with seed = 0 account_address = "0x2af9427c5a277474c079a1283c880ee8a6f0f8fbf73ce969c08d88befec1bba" private_key = "0x1800000000300000180000000000030000000000003006001800006600" -world_address = "0x70058e3886cb7411e8a77db90ee3dd453ac16b763b30bd99b3c8440fe42056e" +world_address = "0x52ee4d3cba58d1a0462bbfb6813bf5aa1b35078c3b859cded2b727c1d9469ea" +ipfs_config.url = "https://ipfs.infura.io:5001" +ipfs_config.username = "2EBrzr7ZASQZKH32sl2xWauXPSA" +ipfs_config.password = "12290b883db9138a8ae3363b6739d220" [init_call_args] "ns-others" = ["0xff"] diff --git a/examples/spawn-and-move/manifest_dev.json b/examples/spawn-and-move/manifest_dev.json index 7a92b22fe5..057ee7d64d 100644 --- a/examples/spawn-and-move/manifest_dev.json +++ b/examples/spawn-and-move/manifest_dev.json @@ -1,7 +1,7 @@ { "world": { - "class_hash": "0x79d9ce84b97bcc2a631996c3100d57966fc2f5b061fb1ec4dfd0040976bcac6", - "address": "0x70058e3886cb7411e8a77db90ee3dd453ac16b763b30bd99b3c8440fe42056e", + "class_hash": "0x45575a88cc5cef1e444c77ce60b7b4c9e73a01cbbe20926d5a4c72a94011410", + "address": "0x52ee4d3cba58d1a0462bbfb6813bf5aa1b35078c3b859cded2b727c1d9469ea", "seed": "dojo_examples", "name": "example", "entrypoints": [ @@ -92,6 +92,10 @@ { "name": "metadata_uri", "type": "core::byte_array::ByteArray" + }, + { + "name": "metadata_hash", + "type": "core::felt252" } ] }, @@ -1002,6 +1006,11 @@ "name": "uri", "type": "core::byte_array::ByteArray", "kind": "data" + }, + { + "name": "hash", + "type": "core::felt252", + "kind": "data" } ] }, @@ -1243,7 +1252,7 @@ }, "contracts": [ { - "address": "0x1cc5853cbf65c96d7c3c8f951e6315f3c1f19a34ace7b579da9ba87b974dce3", + "address": "0x7e8a52c68b243d3a86a55c04ccec2edc760252d5952566d3af4001bd6cf38f3", "class_hash": "0x636c2cf31b094097625cb5ada96f54ee9a3f7bc6d8cde00cc85e5ef0c622c8b", "abi": [ { @@ -1584,7 +1593,7 @@ ] }, { - "address": "0x32bc6ef441c52acfc62ee1186885760803d88e165119fdad6e16f9424547981", + "address": "0x50b1497d463d52cbeb5919a35a82360ea6702db2b9c62c2d69c167995f34c08", "class_hash": "0x16e8d864b5b5484bad069133a751f72961f9e5edade4f4f3b3b6076f6394c5b", "abi": [ { @@ -1778,7 +1787,7 @@ ] }, { - "address": "0x7e5b903baa82407f74bd0a573352e0ca33f2b9fac3bcc2e35230119841cd08", + "address": "0x4b41a2abaeff170f3a04acb0144790a5a812e25e7a735dfef959247cfeb527", "class_hash": "0x43e7cc34a35abdb9baf6804c71526811d8d3cccf58d66be5a095bd79b9be82e", "abi": [ { @@ -1954,7 +1963,7 @@ ] }, { - "address": "0x72b95d835f6158dd1454c1b4463a91222c6088f1bf5dd2cdd09268f92dad16f", + "address": "0x72a9f501c260b2d13f8988ea172680c5c1fdc085c5b44bdcac8477362ed5290", "class_hash": "0xa1884cbaa79b05287019b513a94f993f0d98ed4d9602370bc2e5aaa04feebc", "abi": [ { diff --git a/scripts/rust_fmt.sh b/scripts/rust_fmt.sh index db5636de2e..9b3c2f7358 100755 --- a/scripts/rust_fmt.sh +++ b/scripts/rust_fmt.sh @@ -1,3 +1,11 @@ #!/bin/bash -cargo +nightly-2024-08-28 fmt --check --all -- "$@" + +option="--check" + +if [ "$1" == "--fix" ]; then + option="" + shift +fi + +cargo +nightly-2024-08-28 fmt $option --all -- "$@" diff --git a/spawn-and-move-db.tar.gz b/spawn-and-move-db.tar.gz index e4f393ec63..e8c46c8aa7 100644 Binary files a/spawn-and-move-db.tar.gz and b/spawn-and-move-db.tar.gz differ diff --git a/types-test-db.tar.gz b/types-test-db.tar.gz index ad804bef55..84700bbb69 100644 Binary files a/types-test-db.tar.gz and b/types-test-db.tar.gz differ