diff --git a/bin/sozo/src/commands/migrate.rs b/bin/sozo/src/commands/migrate.rs index 713eb8bc73..61391d2eba 100644 --- a/bin/sozo/src/commands/migrate.rs +++ b/bin/sozo/src/commands/migrate.rs @@ -3,6 +3,7 @@ use clap::Args; use colored::Colorize; use dojo_utils::{self, TxnConfig}; use dojo_world::contracts::WorldContract; +use dojo_world::metadata::IpfsMetadataService; use scarb::core::{Config, Workspace}; use sozo_ops::migrate::{Migration, MigrationResult}; use sozo_ops::migration_ui::MigrationUi; @@ -19,6 +20,11 @@ use super::options::transaction::TransactionOptions; use super::options::world::WorldOptions; use crate::utils; +// TODO: to remove and to be read from environment variables +const IPFS_CLIENT_URL: &str = "https://ipfs.infura.io:5001"; +const IPFS_USERNAME: &str = "2EBrzr7ZASQZKH32sl2xWauXPSA"; +const IPFS_PASSWORD: &str = "12290b883db9138a8ae3363b6739d220"; + #[derive(Debug, Clone, Args)] pub struct MigrateArgs { #[command(flatten)] @@ -75,7 +81,13 @@ impl MigrateArgs { let MigrationResult { manifest, has_changes } = migration.migrate(&mut spinner).await.context("Migration failed.")?; - migration.upload_metadata(&mut spinner).await.context("Metadata upload failed.")?; + let mut metadata_service = + IpfsMetadataService::new(IPFS_CLIENT_URL, IPFS_USERNAME, IPFS_PASSWORD)?; + + migration + .upload_metadata(&mut spinner, &mut metadata_service) + .await + .context("Metadata upload failed.")?; spinner.update_text("Writing manifest..."); ws.write_manifest_profile(manifest).context("🪦 Failed to write manifest.")?; diff --git a/crates/dojo/world/src/metadata/fake_metadata_service.rs b/crates/dojo/world/src/metadata/fake_metadata_service.rs new file mode 100644 index 0000000000..bceaa3a101 --- /dev/null +++ b/crates/dojo/world/src/metadata/fake_metadata_service.rs @@ -0,0 +1,30 @@ +use std::collections::HashMap; +use std::hash::{DefaultHasher, Hash, Hasher}; + +use anyhow::Result; + +use super::metadata_service::MetadataService; + +#[derive(Debug, Default)] +pub struct FakeMetadataService { + data: HashMap>, +} + +#[allow(async_fn_in_trait)] +impl MetadataService for FakeMetadataService { + 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> { + Ok(self.data.get(&uri).cloned().unwrap_or(Vec::::new())) + } +} diff --git a/crates/dojo/world/src/metadata/ipfs.rs b/crates/dojo/world/src/metadata/ipfs.rs deleted file mode 100644 index cf732c67b3..0000000000 --- a/crates/dojo/world/src/metadata/ipfs.rs +++ /dev/null @@ -1,50 +0,0 @@ -use std::io::Cursor; - -use anyhow::Result; -#[cfg(test)] -use futures::TryStreamExt; -use ipfs_api_backend_hyper::{IpfsApi, TryFromUri}; - -const IPFS_CLIENT_URL: &str = "https://ipfs.infura.io:5001"; -const IPFS_USERNAME: &str = "2EBrzr7ZASQZKH32sl2xWauXPSA"; -const IPFS_PASSWORD: &str = "12290b883db9138a8ae3363b6739d220"; - -pub struct IpfsClient { - client: ipfs_api_backend_hyper::IpfsClient, -} - -impl IpfsClient { - pub fn new() -> Result { - Ok(Self { - client: ipfs_api_backend_hyper::IpfsClient::from_str(IPFS_CLIENT_URL)? - .with_credentials(IPFS_USERNAME, IPFS_PASSWORD), - }) - } - - /// Upload a `data` on IPFS and get a IPFS URI. - /// - /// # Arguments - /// * `data`: the data to upload - /// - /// # Returns - /// Result - returns the IPFS URI or a Anyhow error. - pub(crate) async fn upload(&self, data: T) -> Result - where - T: AsRef<[u8]> + std::marker::Send + std::marker::Sync + std::marker::Unpin + 'static, - { - let reader = Cursor::new(data); - let response = self.client.add(reader).await?; - Ok(format!("ipfs://{}", response.hash)) - } - - #[cfg(test)] - pub(crate) 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/metadata/ipfs_service.rs b/crates/dojo/world/src/metadata/ipfs_service.rs new file mode 100644 index 0000000000..ce04b48a07 --- /dev/null +++ b/crates/dojo/world/src/metadata/ipfs_service.rs @@ -0,0 +1,48 @@ +use std::io::Cursor; + +use anyhow::Result; +#[cfg(test)] +use futures::TryStreamExt; +use ipfs_api_backend_hyper::{IpfsApi, TryFromUri}; + +use super::metadata_service::MetadataService; + +pub struct IpfsMetadataService { + client: ipfs_api_backend_hyper::IpfsClient, +} + +// impl required by clippy +impl std::fmt::Debug for IpfsMetadataService { + fn fmt(&self, _f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + Ok(()) + } +} + +impl IpfsMetadataService { + pub fn new(client_url: &str, username: &str, password: &str) -> Result { + Ok(Self { + client: ipfs_api_backend_hyper::IpfsClient::from_str(client_url)? + .with_credentials(username, password), + }) + } +} + +#[allow(async_fn_in_trait)] +impl MetadataService for IpfsMetadataService { + async fn upload(&mut self, data: Vec) -> Result { + let reader = Cursor::new(data); + let response = self.client.add(reader).await?; + 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/metadata/metadata_service.rs b/crates/dojo/world/src/metadata/metadata_service.rs new file mode 100644 index 0000000000..b435e5f2f9 --- /dev/null +++ b/crates/dojo/world/src/metadata/metadata_service.rs @@ -0,0 +1,9 @@ +use anyhow::Result; + +#[allow(async_fn_in_trait)] +pub trait MetadataService: std::marker::Send + std::marker::Sync + std::marker::Unpin { + async fn upload(&mut self, data: Vec) -> Result; + + #[cfg(test)] + async fn get(&self, uri: String) -> Result>; +} 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..61e1d8488b --- /dev/null +++ b/crates/dojo/world/src/metadata/metadata_storage.rs @@ -0,0 +1,78 @@ +use std::hash::{DefaultHasher, Hash, Hasher}; + +use anyhow::Result; +use serde_json::json; +use starknet_crypto::Felt; + +use super::metadata_service::MetadataService; +use crate::config::metadata_config::{ResourceMetadata, WorldMetadata}; +use crate::uri::Uri; + +/// Helper function to compute metadata hash using the Hash trait impl. +fn compute_metadata_hash(data: T) -> u64 +where + T: Hash, +{ + let mut hasher = DefaultHasher::new(); + data.hash(&mut hasher); + hasher.finish() +} + +async fn upload_uri(uri: &Option, service: &mut impl MetadataService) -> 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()) + } +} + +#[allow(async_fn_in_trait)] +pub trait MetadataStorage { + async fn upload(&self, service: &mut impl MetadataService) -> Result; + + async fn upload_if_changed( + &self, + service: &mut impl MetadataService, + 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 MetadataService) -> Result { + let mut meta = self.clone(); + + meta.icon_uri = upload_uri(&self.icon_uri, service).await?; + meta.cover_uri = upload_uri(&self.cover_uri, service).await?; + + let serialized = json!(meta).to_string(); + service.upload(serialized.as_bytes().to_vec()).await + } +} + +#[allow(async_fn_in_trait)] +impl MetadataStorage for ResourceMetadata { + async fn upload(&self, service: &mut impl MetadataService) -> Result { + let mut meta = self.clone(); + + meta.icon_uri = upload_uri(&self.icon_uri, service).await?; + + let serialized = json!(meta).to_string(); + service.upload(serialized.as_bytes().to_vec()).await + } +} diff --git a/crates/dojo/world/src/metadata/metadata_test.rs b/crates/dojo/world/src/metadata/metadata_test.rs index 6fac76f683..cbf830c0c2 100644 --- a/crates/dojo/world/src/metadata/metadata_test.rs +++ b/crates/dojo/world/src/metadata/metadata_test.rs @@ -7,27 +7,26 @@ use std::str::FromStr; use starknet_crypto::Felt; use url::Url; -use crate::metadata::ipfs::IpfsClient; -use crate::metadata::{MetadataStorage, ResourceMetadata, WorldMetadata}; +use super::fake_metadata_service::FakeMetadataService; +use super::metadata_service::MetadataService; +use super::metadata_storage::MetadataStorage; +use crate::config::metadata_config::{ResourceMetadata, WorldMetadata}; use crate::uri::Uri; +fn test_file_path(filename: &str) -> PathBuf { + fs::canonicalize( + PathBuf::from_str(&format!("./src/metadata/metadata_test_data/{}", filename)).unwrap(), + ) + .unwrap() +} + 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( - fs::canonicalize( - PathBuf::from_str("./src/metadata/metadata_test_data/cover.png").unwrap(), - ) - .unwrap(), - )), - icon_uri: Some(Uri::File( - fs::canonicalize( - PathBuf::from_str("./src/metadata/metadata_test_data/icon.png").unwrap(), - ) - .unwrap(), - )), + cover_uri: Some(Uri::File(test_file_path("./src/metadata/metadata_test_data/cover.png"))), + icon_uri: Some(Uri::File(test_file_path("./src/metadata/metadata_test_data/icon.png"))), website: Some(Url::parse("https://my_world.com").expect("parsing failed")), socials: Some(HashMap::from([ ("twitter".to_string(), "twitter_url".to_string()), @@ -40,12 +39,7 @@ fn build_resource_metadata() -> ResourceMetadata { ResourceMetadata { name: "my model".to_string(), description: Some("my model description".to_string()), - icon_uri: Some(Uri::File( - fs::canonicalize( - PathBuf::from_str("./src/metadata/metadata_test_data/icon.png").unwrap(), - ) - .unwrap(), - )), + icon_uri: Some(Uri::File(test_file_path("./src/metadata/metadata_test_data/icon.png"))), } } @@ -55,20 +49,20 @@ fn assert_ipfs_uri(uri: &Option) { } } -async fn assert_ipfs_content(uri: String, path: PathBuf) { - let ipfs_client = IpfsClient::new().expect("Ipfs client failed"); - let ipfs_data = ipfs_client.get(uri).await.expect("read metadata failed"); +async fn assert_ipfs_content(service: &FakeMetadataService, 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 = FakeMetadataService::default(); + let world_metadata = build_world_metadata(); // first metadata upload without existing hash. - let res = world_metadata.upload_if_changed(Felt::ZERO).await; + 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 @@ -77,13 +71,14 @@ async fn test_world_metadata() { }; // no change => the upload is not done. - let res = world_metadata.upload_if_changed(current_hash).await; + 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(current_hash + Felt::ONE).await; + 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 @@ -94,9 +89,8 @@ async fn test_world_metadata() { 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 ipfs_client = IpfsClient::new().expect("Ipfs client failed"); - let read_metadata = ipfs_client.get(current_uri).await.expect("read metadata failed"); + // 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()); @@ -120,6 +114,7 @@ async fn test_world_metadata() { 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(), @@ -128,21 +123,22 @@ async fn test_world_metadata() { 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; - - // TODO: would be nice to fake IpfsClient for tests } #[tokio::test] async fn test_resource_metadata() { + let mut metadata_service = FakeMetadataService::default(); + let resource_metadata = build_resource_metadata(); // first metadata upload without existing hash. - let res = resource_metadata.upload_if_changed(Felt::ZERO).await; + let res = resource_metadata.upload_if_changed(&mut metadata_service, Felt::ZERO).await; assert!(res.is_ok()); let res = res.unwrap(); @@ -150,14 +146,15 @@ async fn test_resource_metadata() { let (current_uri, current_hash) = res.unwrap(); // no change => the upload is not done. - let res = resource_metadata.upload_if_changed(current_hash).await; + 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(current_hash + Felt::ONE).await; + let res = + resource_metadata.upload_if_changed(&mut metadata_service, current_hash + Felt::ONE).await; assert!(res.is_ok()); let res = res.unwrap(); @@ -168,8 +165,7 @@ async fn test_resource_metadata() { assert_eq!(new_hash, current_hash); // read back the metadata stored on IPFS to be sure it is correctly written - let ipfs_client = IpfsClient::new().expect("Ipfs client failed"); - let read_metadata = ipfs_client.get(current_uri).await.expect("read metadata failed"); + 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()); @@ -184,6 +180,7 @@ async fn test_resource_metadata() { 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(), diff --git a/crates/dojo/world/src/metadata/mod.rs b/crates/dojo/world/src/metadata/mod.rs index 17fa3d7b7f..ae68b19514 100644 --- a/crates/dojo/world/src/metadata/mod.rs +++ b/crates/dojo/world/src/metadata/mod.rs @@ -1,84 +1,11 @@ -use std::hash::{DefaultHasher, Hash, Hasher}; - -use anyhow::Result; -use async_trait::async_trait; -use ipfs::IpfsClient; -use serde_json::json; -use starknet_crypto::Felt; - -use crate::config::metadata_config::{ResourceMetadata, WorldMetadata}; -use crate::uri::Uri; - -mod ipfs; +pub mod ipfs_service; +pub use ipfs_service::IpfsMetadataService; +pub mod metadata_storage; +pub use metadata_storage::MetadataStorage; +pub mod metadata_service; +pub use metadata_service::MetadataService; +pub mod fake_metadata_service; +pub use fake_metadata_service::FakeMetadataService; #[cfg(test)] mod metadata_test; - -/// Helper function to compute metadata hash using the Hash trait impl. -fn compute_metadata_hash(data: T) -> u64 -where - T: Hash, -{ - let mut hasher = DefaultHasher::new(); - data.hash(&mut hasher); - hasher.finish() -} - -#[async_trait] -pub trait MetadataStorage { - async fn upload(&self) -> Result; - - async fn upload_if_changed(&self, 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().await?; - return Ok(Some((new_uri, new_hash))); - } - - Ok(None) - } -} - -#[async_trait] -impl MetadataStorage for WorldMetadata { - async fn upload(&self) -> Result { - let mut meta = self.clone(); - - let ipfs_client = IpfsClient::new()?; - - if let Some(Uri::File(icon)) = &self.icon_uri { - let icon_data = std::fs::read(icon)?; - meta.icon_uri = Some(Uri::Ipfs(ipfs_client.upload(icon_data).await?)); - }; - - if let Some(Uri::File(cover)) = &self.cover_uri { - let cover_data = std::fs::read(cover)?; - meta.cover_uri = Some(Uri::Ipfs(ipfs_client.upload(cover_data).await?)); - }; - - let serialized = json!(meta).to_string(); - ipfs_client.upload(serialized).await - } -} - -#[async_trait] -impl MetadataStorage for ResourceMetadata { - async fn upload(&self) -> Result { - let mut meta = self.clone(); - - let ipfs_client = IpfsClient::new()?; - - if let Some(Uri::File(icon)) = &self.icon_uri { - let icon_data = std::fs::read(icon)?; - meta.icon_uri = Some(Uri::Ipfs(ipfs_client.upload(icon_data).await?)); - }; - - let serialized = json!(meta).to_string(); - ipfs_client.upload(serialized).await - } -} diff --git a/crates/sozo/ops/src/migrate/mod.rs b/crates/sozo/ops/src/migrate/mod.rs index 93683dbe25..748c3e684b 100644 --- a/crates/sozo/ops/src/migrate/mod.rs +++ b/crates/sozo/ops/src/migrate/mod.rs @@ -29,7 +29,7 @@ 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::metadata::{MetadataService, MetadataStorage}; use dojo_world::remote::ResourceRemote; use dojo_world::{utils, ResourceType}; use starknet::accounts::{ConnectedAccount, SingleOwnerAccount}; @@ -111,7 +111,11 @@ where /// # Arguments /// /// # Returns - pub async fn upload_metadata(&self, ui: &mut MigrationUi) -> anyhow::Result<()> { + pub async fn upload_metadata( + &self, + ui: &mut MigrationUi, + service: &mut impl MetadataService, + ) -> anyhow::Result<()> { ui.update_text("Uploading metadata..."); let mut invoker = Invoker::new(&self.world.account, self.txn_config); @@ -121,7 +125,7 @@ where self.diff.resources.get(&Felt::ZERO).map_or(Felt::ZERO, |r| r.metadata_hash()); let new_metadata = WorldMetadata::from(self.diff.profile_config.world.clone()); - let res = new_metadata.upload_if_changed(current_hash).await?; + let res = new_metadata.upload_if_changed(service, current_hash).await?; if let Some((new_uri, new_hash)) = res { invoker.add_call(self.world.set_metadata_getcall(&ResourceMetadata { @@ -133,19 +137,19 @@ where // contracts if let Some(configs) = &self.diff.profile_config.contracts { - let calls = self.upload_metadata_from_resource_config(configs).await?; + 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(configs).await?; + 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(configs).await?; + let calls = self.upload_metadata_from_resource_config(service, configs).await?; invoker.extend_calls(calls); } @@ -165,6 +169,7 @@ where async fn upload_metadata_from_resource_config( &self, + service: &mut impl MetadataService, config: &[ResourceConfig], ) -> anyhow::Result> { let mut calls = vec![]; @@ -177,7 +182,7 @@ where let new_metadata = metadata_config::ResourceMetadata::from(item.clone()); - let res = new_metadata.upload_if_changed(current_hash).await?; + let res = new_metadata.upload_if_changed(service, current_hash).await?; if let Some((new_uri, new_hash)) = res { calls.push(self.world.set_metadata_getcall(&ResourceMetadata { diff --git a/crates/sozo/ops/src/tests/migration.rs b/crates/sozo/ops/src/tests/migration.rs index 74618f5c5b..24e24f4d54 100644 --- a/crates/sozo/ops/src/tests/migration.rs +++ b/crates/sozo/ops/src/tests/migration.rs @@ -8,6 +8,7 @@ use dojo_utils::TxnConfig; use dojo_world::config::ResourceConfig; use dojo_world::contracts::WorldContract; use dojo_world::diff::WorldDiff; +use dojo_world::metadata::FakeMetadataService; use katana_runner::RunnerCtx; use scarb::compiler::Profile; use sozo_scarbext::WorkspaceExt; @@ -63,7 +64,8 @@ async fn migrate_spawn_and_move(sequencer: &RunnerCtx, with_metadata: bool) -> M let res = migration.migrate(&mut ui).await.expect("Migration spawn-and-move failed."); if with_metadata { - migration.upload_metadata(&mut ui).await.expect("Upload metadata failed"); + let mut service = FakeMetadataService::default(); + migration.upload_metadata(&mut ui, &mut service).await.expect("Upload metadata failed"); } res