diff --git a/bin/sozo/src/commands/migrate.rs b/bin/sozo/src/commands/migrate.rs index 95a2d33025..713eb8bc73 100644 --- a/bin/sozo/src/commands/migrate.rs +++ b/bin/sozo/src/commands/migrate.rs @@ -75,6 +75,8 @@ 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.")?; + spinner.update_text("Writing manifest..."); ws.write_manifest_profile(manifest).context("🪦 Failed to write 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/world/src/config/metadata_config.rs b/crates/dojo/world/src/config/metadata_config.rs index f1409df0ab..11acd11351 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,61 @@ 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 (for contracts, models, ...) +#[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: config.name, + 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..91679ec065 100644 --- a/crates/dojo/world/src/config/mod.rs +++ b/crates/dojo/world/src/config/mod.rs @@ -4,10 +4,12 @@ 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 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..85903d188a 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,24 @@ mod tests { website = "https://example.com" socials = { "twitter" = "test", "discord" = "test" } + [[models]] + tag = "ns1-m1" + name = "m1" + description = "This is the m1 model" + icon_uri = "ipfs://dojo/m1.png" + + [[contracts]] + tag = "ns1-c1" + name = "c1" + description = "This is the c1 contract" + icon_uri = "ipfs://dojo/c1.png" + + [[events]] + tag = "ns1-e1" + name = "e1" + description = "This is the e1 event" + icon_uri = "ipfs://dojo/e1.png" + [namespace] default = "test" mappings = { "test" = ["test2"] } @@ -190,6 +212,30 @@ 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].name, "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].name, "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].name, "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..0d2bc255a2 --- /dev/null +++ b/crates/dojo/world/src/config/resource_config.rs @@ -0,0 +1,11 @@ +use serde::Deserialize; + +use crate::uri::Uri; + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ResourceConfig { + pub tag: String, + pub name: String, + pub description: Option, + pub icon_uri: Option, +} 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/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/metadata/ipfs.rs b/crates/dojo/world/src/metadata/ipfs.rs new file mode 100644 index 0000000000..3bd3a042f1 --- /dev/null +++ b/crates/dojo/world/src/metadata/ipfs.rs @@ -0,0 +1,27 @@ +use std::io::Cursor; + +use anyhow::Result; +use ipfs_api_backend_hyper::{IpfsApi, IpfsClient, TryFromUri}; + +const IPFS_CLIENT_URL: &str = "https://ipfs.infura.io:5001"; +const IPFS_USERNAME: &str = "2EBrzr7ZASQZKH32sl2xWauXPSA"; +const IPFS_PASSWORD: &str = "12290b883db9138a8ae3363b6739d220"; + +/// 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(data: T) -> Result +where + T: AsRef<[u8]> + std::marker::Send + std::marker::Sync + std::marker::Unpin + 'static, +{ + let client = + IpfsClient::from_str(IPFS_CLIENT_URL)?.with_credentials(IPFS_USERNAME, IPFS_PASSWORD); + + let reader = Cursor::new(data); + let response = client.add(reader).await?; + Ok(format!("ipfs://{}", response.hash)) +} diff --git a/crates/dojo/world/src/metadata/mod.rs b/crates/dojo/world/src/metadata/mod.rs index 16a61c9701..2efafd54e2 100644 --- a/crates/dojo/world/src/metadata/mod.rs +++ b/crates/dojo/world/src/metadata/mod.rs @@ -1,3 +1,73 @@ -//! Metadata for the world. +use std::hash::{DefaultHasher, Hash, Hasher}; -pub mod world; +use anyhow::Result; +use serde_json::json; +use starknet_crypto::Felt; + +use crate::config::metadata_config::{ResourceMetadata, WorldMetadata}; +use crate::uri::Uri; + +mod ipfs; + +/// 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() +} + +#[allow(async_fn_in_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) + } +} + +impl MetadataStorage for WorldMetadata { + async fn upload(&self) -> Result { + let mut meta = self.clone(); + + if let Some(Uri::File(icon)) = &self.icon_uri { + let icon_data = std::fs::read(icon)?; + meta.icon_uri = Some(Uri::Ipfs(ipfs::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::upload(cover_data).await?)); + }; + + let serialized = json!(meta).to_string(); + ipfs::upload(serialized).await + } +} + +impl MetadataStorage for ResourceMetadata { + async fn upload(&self) -> Result { + let mut meta = self.clone(); + + if let Some(Uri::File(icon)) = &self.icon_uri { + let icon_data = std::fs::read(icon)?; + meta.icon_uri = Some(Uri::Ipfs(ipfs::upload(icon_data).await?)); + }; + + let serialized = json!(meta).to_string(); + ipfs::upload(serialized).await + } +} 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..3a3b91a814 100644 --- a/crates/dojo/world/src/remote/events_to_remote.rs +++ b/crates/dojo/world/src/remote/events_to_remote.rs @@ -55,6 +55,7 @@ impl WorldRemote { world::ContractInitialized::event_selector(), world::WriterUpdated::event_selector(), world::OwnerUpdated::event_selector(), + world::MetadataUpdate::event_selector(), ]]; let filter = EventFilter { @@ -250,6 +251,13 @@ impl WorldRemote { trace!(?e, "Owner updated."); } + WorldEvent::MetadataUpdate(e) => { + // 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 +524,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/resource.rs b/crates/dojo/world/src/remote/resource.rs index b44e217db5..c3904136e9 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,25 @@ impl ResourceRemote { } } + 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/uri.rs b/crates/dojo/world/src/uri.rs index b83b358eb9..29c2ee45fb 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 diff --git a/crates/sozo/ops/Cargo.toml b/crates/sozo/ops/Cargo.toml index 54a6982f8f..a5173a5835 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 = [ "metadata" ] } 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..35a6addbda 100644 --- a/crates/sozo/ops/src/migrate/mod.rs +++ b/crates/sozo/ops/src/migrate/mod.rs @@ -20,13 +20,16 @@ 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::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::{utils, ResourceType}; use starknet::accounts::{ConnectedAccount, SingleOwnerAccount}; @@ -103,6 +106,85 @@ where }) } + /// Upload resources metadata to IPFS and update the ResourceMetadata Dojo model. + /// + /// # Arguments + /// + /// # Returns + pub async fn upload_metadata(&self, ui: &mut MigrationUi) -> anyhow::Result<()> { + ui.update_text("Uploading metadata..."); + + let mut invoker = Invoker::new(&self.world.account, self.txn_config); + + // world + let current_hash = + 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?; + + if let Some((new_uri, new_hash)) = res { + invoker.add_call(self.world.set_metadata_getcall(&ResourceMetadata { + resource_id: Felt::ZERO, + 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(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?; + 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, + config: &Vec, + ) -> 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(current_hash).await?; + + if let Some((new_uri, new_hash)) = res { + 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/examples/spawn-and-move/dojo_dev.toml b/examples/spawn-and-move/dojo_dev.toml index 23441d37ae..e0416047e7 100644 --- a/examples/spawn-and-move/dojo_dev.toml +++ b/examples/spawn-and-move/dojo_dev.toml @@ -11,7 +11,7 @@ rpc_url = "http://localhost:5050/" # Default account for katana with seed = 0 account_address = "0x2af9427c5a277474c079a1283c880ee8a6f0f8fbf73ce969c08d88befec1bba" private_key = "0x1800000000300000180000000000030000000000003006001800006600" -world_address = "0x70058e3886cb7411e8a77db90ee3dd453ac16b763b30bd99b3c8440fe42056e" +world_address = "0x52ee4d3cba58d1a0462bbfb6813bf5aa1b35078c3b859cded2b727c1d9469ea" [init_call_args] "ns-others" = ["0xff"] diff --git a/spawn-and-move-db.tar.gz b/spawn-and-move-db.tar.gz index ebf32b5706..720c141c46 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 bf1e74a87e..2e1fc83677 100644 Binary files a/types-test-db.tar.gz and b/types-test-db.tar.gz differ