From e6f3f749a5f0af3ea0d3981ec340276fa4a90124 Mon Sep 17 00:00:00 2001 From: Tarrence van As Date: Mon, 9 Oct 2023 10:51:02 -0400 Subject: [PATCH] Upload world metadata to ipfs --- Cargo.lock | 308 +++++++++++++++++- crates/dojo-world/Cargo.toml | 1 + crates/dojo-world/src/metadata.rs | 162 +++++---- crates/dojo-world/src/metadata_test.rs | 63 ++++ .../src/metadata_test_data/cover.png | Bin 0 -> 2222 bytes crates/dojo-world/src/migration/strategy.rs | 10 - crates/sozo/src/commands/dev.rs | 22 +- crates/sozo/src/commands/migrate.rs | 9 +- .../sozo/src/ops/migration/migration_test.rs | 42 ++- crates/sozo/src/ops/migration/mod.rs | 81 +++-- crates/torii/core/Cargo.toml | 1 + crates/torii/core/src/sql_test.rs | 15 +- examples/ecs/Scarb.toml | 2 + examples/ecs/assets/cover.png | Bin 0 -> 19098 bytes examples/ecs/assets/icon.png | Bin 0 -> 2222 bytes 15 files changed, 554 insertions(+), 162 deletions(-) create mode 100644 crates/dojo-world/src/metadata_test.rs create mode 100644 crates/dojo-world/src/metadata_test_data/cover.png create mode 100644 examples/ecs/assets/cover.png create mode 100644 examples/ecs/assets/icon.png diff --git a/Cargo.lock b/Cargo.lock index 221db839c3..05935d4d43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -294,6 +294,12 @@ dependencies = [ "rand", ] +[[package]] +name = "arrayref" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b4930d2cb77ce62f89ee5d5289b4ac049559b1c45539271f5ed4fdc7db34545" + [[package]] name = "arrayvec" version = "0.7.4" @@ -580,6 +586,12 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base-x" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" + [[package]] name = "base16ct" version = "0.2.0" @@ -1106,7 +1118,7 @@ dependencies = [ "serde", "smol_str", "thiserror", - "toml", + "toml 0.7.8", ] [[package]] @@ -1651,6 +1663,22 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "common-multipart-rfc7578" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baee326bc603965b0f26583e1ecd7c111c41b49bd92a344897476a352798869" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http", + "mime", + "mime_guess", + "rand", + "thiserror", +] + [[package]] name = "console" version = "0.15.7" @@ -1725,6 +1753,15 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + [[package]] name = "cpufeatures" version = "0.2.9" @@ -1983,6 +2020,26 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" +[[package]] +name = "data-encoding-macro" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c904b33cc60130e1aeea4956ab803d08a3f4a0ca82d64ed757afac3891f2bb99" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fdf3fce3ce863539ec1d7fd1b6dcc3c645663376b43ed376bbf887733e4f772" +dependencies = [ + "data-encoding", + "syn 1.0.109", +] + [[package]] name = "deno_task_shell" version = "0.13.2" @@ -2121,7 +2178,16 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" dependencies = [ - "dirs-sys", + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", ] [[package]] @@ -2130,7 +2196,7 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ - "dirs-sys", + "dirs-sys 0.4.1", ] [[package]] @@ -2143,6 +2209,17 @@ dependencies = [ "dirs-sys-next", ] +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "dirs-sys" version = "0.4.1" @@ -2274,7 +2351,7 @@ dependencies = [ "starknet", "thiserror", "tokio", - "toml", + "toml 0.7.8", "tracing", "url", ] @@ -2310,6 +2387,7 @@ dependencies = [ "dojo-test-utils", "dojo-types", "futures", + "ipfs-api-backend-hyper", "reqwest", "scarb", "serde", @@ -2320,7 +2398,7 @@ dependencies = [ "starknet-crypto 0.6.0", "thiserror", "tokio", - "toml", + "toml 0.7.8", "tracing", "url", ] @@ -2604,7 +2682,7 @@ dependencies = [ "serde", "serde_json", "syn 2.0.37", - "toml", + "toml 0.7.8", "walkdir", ] @@ -2760,7 +2838,7 @@ checksum = "de34e484e7ae3cab99fbfd013d6c5dc7f9013676a4e0e414d8b12e1213e8b3ba" dependencies = [ "cfg-if", "const-hex", - "dirs", + "dirs 5.0.1", "dunce", "ethers-core", "glob", @@ -4106,6 +4184,34 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-multipart-rfc7578" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0eb2cf73e96e9925f4bed948e763aa2901c2f1a3a5f713ee41917433ced6671" +dependencies = [ + "bytes", + "common-multipart-rfc7578", + "futures-core", + "http", + "hyper", +] + +[[package]] +name = "hyper-rustls" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788965e61b367cd03a62950836d5cd41560c3577d90e40e0819373194d1661c" +dependencies = [ + "http", + "hyper", + "log", + "rustls 0.20.9", + "rustls-native-certs", + "tokio", + "tokio-rustls 0.23.4", +] + [[package]] name = "hyper-rustls" version = "0.24.1" @@ -4368,6 +4474,47 @@ dependencies = [ "winapi", ] +[[package]] +name = "ipfs-api-backend-hyper" +version = "0.6.0" +source = "git+https://github.com/ferristseng/rust-ipfs-api?rev=af2c17f7b19ef5b9898f458d97a90055c3605633#af2c17f7b19ef5b9898f458d97a90055c3605633" +dependencies = [ + "async-trait", + "base64 0.13.1", + "bytes", + "futures", + "http", + "hyper", + "hyper-multipart-rfc7578", + "hyper-rustls 0.23.2", + "ipfs-api-prelude", + "thiserror", +] + +[[package]] +name = "ipfs-api-prelude" +version = "0.6.0" +source = "git+https://github.com/ferristseng/rust-ipfs-api?rev=af2c17f7b19ef5b9898f458d97a90055c3605633#af2c17f7b19ef5b9898f458d97a90055c3605633" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "common-multipart-rfc7578", + "dirs 4.0.0", + "futures", + "http", + "multiaddr", + "multibase", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "walkdir", +] + [[package]] name = "ipnet" version = "2.8.0" @@ -4949,6 +5096,61 @@ dependencies = [ "version_check", ] +[[package]] +name = "multiaddr" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b36f567c7099511fa8612bbbb52dda2419ce0bdbacf31714e3a5ffdb766d3bd" +dependencies = [ + "arrayref", + "byteorder", + "data-encoding", + "log", + "multibase", + "multihash", + "percent-encoding", + "serde", + "static_assertions", + "unsigned-varint", + "url", +] + +[[package]] +name = "multibase" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b3539ec3c1f04ac9748a260728e855f261b4977f5c3406612c884564f329404" +dependencies = [ + "base-x", + "data-encoding", + "data-encoding-macro", +] + +[[package]] +name = "multihash" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835d6ff01d610179fbce3de1694d007e500bf33a7f29689838941d6bf783ae40" +dependencies = [ + "core2", + "multihash-derive", + "unsigned-varint", +] + +[[package]] +name = "multihash-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6d4752e6230d8ef7adf7bd5d8c4b1f6561c1014c5ba9a37445ccefe18aa1db" +dependencies = [ + "proc-macro-crate", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", + "synstructure", +] + [[package]] name = "multimap" version = "0.8.3" @@ -5222,6 +5424,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + [[package]] name = "option-ext" version = "0.2.0" @@ -5735,12 +5943,12 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "1.3.1" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +checksum = "e17d47ce914bf4de440332250b0edd23ce48c005f59fab39d3335866b114f11a" dependencies = [ - "once_cell", - "toml_edit", + "thiserror", + "toml 0.5.11", ] [[package]] @@ -6090,7 +6298,7 @@ dependencies = [ "http", "http-body", "hyper", - "hyper-rustls", + "hyper-rustls 0.24.1", "ipnet", "js-sys", "log", @@ -6243,6 +6451,18 @@ dependencies = [ "sct", ] +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pemfile" version = "1.0.3" @@ -6415,7 +6635,7 @@ dependencies = [ "smol_str", "thiserror", "tokio", - "toml", + "toml 0.7.8", "toml_edit", "tracing", "tracing-log", @@ -6478,6 +6698,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "schannel" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88" +dependencies = [ + "windows-sys 0.48.0", +] + [[package]] name = "schemars" version = "0.8.15" @@ -6551,6 +6780,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.19" @@ -7398,7 +7650,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597e3a746727984cb7ea2487b6a40726cad0dbe86628e7d429aa6b8c4c153db4" dependencies = [ - "dirs", + "dirs 5.0.1", "fs2", "hex", "once_cell", @@ -7440,6 +7692,18 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "synstructure" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-xid", +] + [[package]] name = "tap" version = "1.0.1" @@ -7702,6 +7966,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "toml" version = "0.7.8" @@ -7907,6 +8180,7 @@ dependencies = [ "lazy_static", "log", "once_cell", + "scarb", "scarb-ui", "serde", "serde_json", @@ -8371,6 +8645,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "unsigned-varint" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6889a77d49f1f013504cec6bf97a2c730394adedaeb1deb5ea08949a50541105" + [[package]] name = "untrusted" version = "0.7.1" diff --git a/crates/dojo-world/Cargo.toml b/crates/dojo-world/Cargo.toml index cb650c7c2e..37602d00cb 100644 --- a/crates/dojo-world/Cargo.toml +++ b/crates/dojo-world/Cargo.toml @@ -16,6 +16,7 @@ camino.workspace = true convert_case.workspace = true dojo-types = { path = "../dojo-types" } futures = "0.3.28" +ipfs-api-backend-hyper = { git = "https://github.com/ferristseng/rust-ipfs-api", rev = "af2c17f7b19ef5b9898f458d97a90055c3605633", features = [ "with-hyper-rustls" ] } reqwest = { version = "0.11.18", default-features = false, features = [ "rustls-tls" ] } scarb.workspace = true serde.workspace = true diff --git a/crates/dojo-world/src/metadata.rs b/crates/dojo-world/src/metadata.rs index 0198872f9a..8108f21ce4 100644 --- a/crates/dojo-world/src/metadata.rs +++ b/crates/dojo-world/src/metadata.rs @@ -1,21 +1,77 @@ +use std::io::Cursor; +use std::path::PathBuf; + +use anyhow::Result; +use ipfs_api_backend_hyper::{IpfsApi, IpfsClient, TryFromUri}; use scarb::core::{ManifestMetadata, Workspace}; -use serde::Deserialize; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::json; +use url::Url; + +#[cfg(test)] +#[path = "metadata_test.rs"] +mod test; -pub fn dojo_metadata_from_workspace(ws: &Workspace<'_>) -> Option { +pub fn dojo_metadata_from_workspace(ws: &Workspace<'_>) -> Option { Some(ws.current_package().ok()?.manifest.metadata.dojo()) } #[derive(Default, Deserialize, Debug, Clone)] -pub struct DojoMetadata { - pub world: Option, +pub struct Metadata { + pub world: Option, pub env: Option, } -#[derive(Default, Deserialize, Debug, Clone)] -pub struct World { +#[derive(Debug)] +pub enum UriParseError { + InvalidUri, + InvalidFileUri, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Uri { + Http(Url), + Ipfs(String), + File(PathBuf), +} + +impl Serialize for Uri { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Uri::Http(url) => serializer.serialize_str(url.as_ref()), + Uri::Ipfs(ipfs) => serializer.serialize_str(ipfs), + Uri::File(path) => serializer.serialize_str(&format!("file://{}", path.display())), + } + } +} + +impl<'de> Deserialize<'de> for Uri { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + if s.starts_with("ipfs://") { + Ok(Uri::Ipfs(s)) + } else if let Some(path) = s.strip_prefix("file://") { + Ok(Uri::File(PathBuf::from(&path))) + } else if let Ok(url) = Url::parse(&s) { + Ok(Uri::Http(url)) + } else { + Err(serde::de::Error::custom("Invalid Uri")) + } + } +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone)] +pub struct WorldMetadata { pub name: Option, pub description: Option, - pub image: Option, + pub cover_uri: Option, + pub icon_uri: Option, } #[derive(Default, Deserialize, Clone, Debug)] @@ -55,7 +111,7 @@ impl Environment { } } -impl World { +impl WorldMetadata { pub fn name(&self) -> Option<&str> { self.name.as_deref() } @@ -63,84 +119,56 @@ impl World { pub fn description(&self) -> Option<&str> { self.description.as_deref() } +} - pub fn image(&self) -> Option<&str> { - self.image.as_deref() +impl WorldMetadata { + pub async fn upload(&self) -> Result { + let mut meta = self.clone(); + let client = IpfsClient::from_str("https://ipfs.infura.io:5001")? + .with_credentials("2EBrzr7ZASQZKH32sl2xWauXPSA", "12290b883db9138a8ae3363b6739d220"); + + 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) } } -impl DojoMetadata { +impl Metadata { pub fn env(&self) -> Option<&Environment> { self.env.as_ref() } - pub fn world(&self) -> Option<&World> { + pub fn world(&self) -> Option<&WorldMetadata> { self.world.as_ref() } } trait MetadataExt { - fn dojo(&self) -> DojoMetadata; + fn dojo(&self) -> Metadata; } impl MetadataExt for ManifestMetadata { - fn dojo(&self) -> DojoMetadata { + fn dojo(&self) -> Metadata { self.tool_metadata .as_ref() .and_then(|e| e.get("dojo")) .cloned() - .map(|v| v.try_into::().unwrap_or_default()) + .map(|v| v.try_into::().unwrap_or_default()) .unwrap_or_default() } } - -#[cfg(test)] -mod test { - use super::DojoMetadata; - - #[test] - fn check_deserialization() { - let metadata: DojoMetadata = toml::from_str( - r#" -[env] -rpc_url = "http://localhost:5050/" -account_address = "0x517ececd29116499f4a1b64b094da79ba08dfd54a3edaa316134c41f8160973" -private_key = "0x1800000000300000180000000000030000000000003006001800006600" -keystore_path = "test/" -keystore_password = "dojo" -world_address = "0x0248cacaeac64c45be0c19ee8727e0bb86623ca7fa3f0d431a6c55e200697e5a" - -[world] -name = "example" -description = "example world" -image = "example.png" - "#, - ) - .unwrap(); - - assert!(metadata.env.is_some()); - let env = metadata.env.unwrap(); - - assert_eq!(env.rpc_url(), Some("http://localhost:5050/")); - assert_eq!( - env.account_address(), - Some("0x517ececd29116499f4a1b64b094da79ba08dfd54a3edaa316134c41f8160973") - ); - assert_eq!( - env.private_key(), - Some("0x1800000000300000180000000000030000000000003006001800006600") - ); - assert_eq!(env.keystore_path(), Some("test/")); - assert_eq!(env.keystore_password(), Some("dojo")); - assert_eq!( - env.world_address(), - Some("0x0248cacaeac64c45be0c19ee8727e0bb86623ca7fa3f0d431a6c55e200697e5a") - ); - - assert!(metadata.world.is_some()); - let world = metadata.world.unwrap(); - - assert_eq!(world.name(), Some("example")); - assert_eq!(world.description(), Some("example world")); - assert_eq!(world.image(), Some("example.png")); - } -} diff --git a/crates/dojo-world/src/metadata_test.rs b/crates/dojo-world/src/metadata_test.rs new file mode 100644 index 0000000000..8525fe8925 --- /dev/null +++ b/crates/dojo-world/src/metadata_test.rs @@ -0,0 +1,63 @@ +use super::WorldMetadata; +use crate::metadata::{Metadata, Uri}; + +#[test] +fn check_metadata_deserialization() { + let metadata: Metadata = toml::from_str( + r#" +[env] +rpc_url = "http://localhost:5050/" +account_address = "0x517ececd29116499f4a1b64b094da79ba08dfd54a3edaa316134c41f8160973" +private_key = "0x1800000000300000180000000000030000000000003006001800006600" +keystore_path = "test/" +keystore_password = "dojo" +world_address = "0x0248cacaeac64c45be0c19ee8727e0bb86623ca7fa3f0d431a6c55e200697e5a" + +[world] +name = "example" +description = "example world" +cover_uri = "file://example_cover.png" +icon_uri = "file://example_icon.png" + "#, + ) + .unwrap(); + + assert!(metadata.env.is_some()); + let env = metadata.env.unwrap(); + + assert_eq!(env.rpc_url(), Some("http://localhost:5050/")); + assert_eq!( + env.account_address(), + Some("0x517ececd29116499f4a1b64b094da79ba08dfd54a3edaa316134c41f8160973") + ); + assert_eq!( + env.private_key(), + Some("0x1800000000300000180000000000030000000000003006001800006600") + ); + assert_eq!(env.keystore_path(), Some("test/")); + assert_eq!(env.keystore_password(), Some("dojo")); + assert_eq!( + env.world_address(), + Some("0x0248cacaeac64c45be0c19ee8727e0bb86623ca7fa3f0d431a6c55e200697e5a") + ); + + assert!(metadata.world.is_some()); + let world = metadata.world.unwrap(); + + assert_eq!(world.name(), Some("example")); + assert_eq!(world.description(), Some("example world")); + assert_eq!(world.cover_uri, Some(Uri::File("example_cover.png".into()))); + assert_eq!(world.icon_uri, Some(Uri::File("example_icon.png".into()))); +} + +#[tokio::test] +async fn world_metadata_hash_and_upload() { + let meta = WorldMetadata { + name: Some("Test World".to_string()), + description: Some("A world used for testing".to_string()), + cover_uri: Some(Uri::File("src/metadata_test_data/cover.png".into())), + icon_uri: None, + }; + + let _ = meta.upload().await.unwrap(); +} diff --git a/crates/dojo-world/src/metadata_test_data/cover.png b/crates/dojo-world/src/metadata_test_data/cover.png new file mode 100644 index 0000000000000000000000000000000000000000..2169b565d02cf59b70b5e304674e3fcb31edfe58 GIT binary patch literal 2222 zcmbtVdpOhW8~;{gQw~c-Dl;>ul1iblzQ3=gZABs_UWZ7Rrs8d8Wm69GW30EWbTo__ z${b#zC5H~m;dT-S5m_x<^N?$7-^|2$91(IX_S)tgoW z0H8%76FmW71%G*?R>6oQsD2R+h)~E40)SH9nuQ=WxLy(INpc2?rCStmfjv$3bOQjU z834p30)PsJ;ywaE)J_2SbP50*?g4=Q?-|cMoM0mNsH>L?5(Dlw1|b|+ZV7(b4wi2P zb1lIOreFXb++YNLumW4Rfp{$FZUVmE3f64}?^%E>Gw{4Am|_m*S%P)jz`GXUQ4_G$ z3bZ4i4xuHpuM3Zdu=K4Cvw6Z-4b78N-chlF!N9Cf%i%a z@10rCFhw(Np>S8KeQ~n*N`xQpqHV;=rR%Qh#iVBiiyg{)6;yeyz}WFg9G5=+V+!{V z&OCFXbbl9foH;DtwW$izT~iZDU|hI7#`GHB8%1EgbBrS7DG}bHAp)~zwwUy%kN@)k z&r|4@^`)$KDDC$nu~mBw_h{sj?ep1YLF*Nt=q({zUT%M*?T&Pl`td^jGqbeQ=`qf2 za|%*GJ{R%+VV5I=P%yvdl*8G!$&c%IT~lOJ?76Nh@58c-&bm^1%g6H56f+1TJ$Ai$ z_WebFzgqEe8jZmyZSWl<1-(<}UJLaZv1dt0vLQke{am?0m(LuS=n?+#b)Wa`W7b6z zi<#2v2U#6^d$lG``bc~G`wkr9_ota6Nudwdh%@_!KP^n0fns9QbOe~ejf6!hi zaOd`naNmv{e-*3}Xrgcm=x=@ZTB|z-+#U65N}&R_Vc(OoCTaWZD6ajwKP7JwH@;(M z=Wg#8(46LP1)hivS`Xm~m_ngHj=`d%3jD`7r<9qJNFH?2n{J}J_^E}zd90$ zI=HLzG68iAFjhA>^7iobVxb;!U))B9FB5(7!ok=shz}*z8BM)bRe%N|;q9j@7Y-Lb zKvM4f%!!E3hLXUcT-k~=sIV5RDB-BaUi_i|lUh2EqfU|9%Dx4x9p?J;Iy{J4(`BYZ zEWu}OyT-2}mxKoh1zc=wk9xFgrw4mNs;W0XAutT*4>U2TN|G^-GlDrsaQv6Mu3!+H zwWrHUS`J!i1V>4$l#+tG%xRj^H&R0B|r6$n2>Mf@w7n! zqI7?gueEUS_AP8PtMKZ7wX_M1^t{?**}Yi3&3s)Ze;~zS%V%{T{m0+8#C(P5EW?hH zq1zqN~?j_xu5Rx*d_-A zbr(ko5&y}-k-1Q~)vV88@D50>8O$1-$Dc4J3!sTw+~dFp)c6jG+Q0vKZUVFGrp2aq?Se-lYf&$ ztQ`^B;K}n)su@SpRf&|N$~wWmy>5Q>Qc@{b96GP2DAK&7BfQ2d7#HsBKHqq}>`c>W zK*Swlk!H$sP--w@r(+Jf>t zo{2qy^3;Ny4xpRSZ+0W5G=DK=@+B%G&m^qu5oBg7Z2TzRVPV1%Eh1+tgnSP!8h$rA zCUf+SIbIl13MOoA0y!5-SLAGf0X@-U%pn*sh0@180ur&F7x`waTY50FQPhn&1&+|l}qHJpkjrfT<_{00vl&L-udAW@?Wj}DU*z5?oh zDHrO#vCy{3G@N0lEqzFXMURq~9cVHW%jM8%V!a%?hH{rXgLwXM=Z(KGJ@7PKKEVz? zxl1_93BEL4@vPx_W6k*X|H$7f)qnlZgB_~FmR5~!K)j3jV^q9sNO3trEOtJX@IS1j By|@4X literal 0 HcmV?d00001 diff --git a/crates/dojo-world/src/migration/strategy.rs b/crates/dojo-world/src/migration/strategy.rs index a8fdefc1d6..d13367d13f 100644 --- a/crates/dojo-world/src/migration/strategy.rs +++ b/crates/dojo-world/src/migration/strategy.rs @@ -128,16 +128,6 @@ where seed.map(poseidon_hash_single).ok_or(anyhow!("Missing seed for World deployment."))?; world.salt = salt; - world.contract_address = get_contract_address( - salt, - diff.world.local, - &[ - executor.as_ref().unwrap().contract_address, - base.as_ref().unwrap().diff.local, - FieldElement::ZERO, - ], - FieldElement::ZERO, - ); } Ok(MigrationStrategy { world_address, world, executor, base, contracts, models }) diff --git a/crates/sozo/src/commands/dev.rs b/crates/sozo/src/commands/dev.rs index b55fb33878..5db6cf1421 100644 --- a/crates/sozo/src/commands/dev.rs +++ b/crates/sozo/src/commands/dev.rs @@ -9,7 +9,6 @@ use cairo_lang_filesystem::db::{AsFilesGroupMut, FilesGroupEx, PrivRawFileConten use cairo_lang_filesystem::ids::FileId; use clap::Args; use dojo_world::manifest::Manifest; -use dojo_world::metadata::{dojo_metadata_from_workspace, DojoMetadata}; use dojo_world::migration::world::WorldDiff; use notify_debouncer_mini::notify::RecursiveMode; use notify_debouncer_mini::{new_debouncer, DebouncedEvent, DebouncedEventKind}; @@ -76,7 +75,6 @@ struct DevContext<'a> { pub db: RootDatabase, pub unit: CompilationUnit, pub ws: Workspace<'a>, - pub dojo_metadata: Option, } fn load_context(config: &Config) -> Result> { @@ -90,8 +88,7 @@ fn load_context(config: &Config) -> Result> { // we have only 1 unit in projects let unit = compilation_units.get(0).unwrap(); let db = build_scarb_root_database(unit, &ws).unwrap(); - let dojo_metadata = dojo_metadata_from_workspace(&ws); - Ok(DevContext { db, unit: unit.clone(), ws, dojo_metadata }) + Ok(DevContext { db, unit: unit.clone(), ws }) } fn build(context: &mut DevContext<'_>) -> Result<()> { @@ -132,16 +129,9 @@ where if total_diffs == 0 { return Ok((new_manifest, world_address)); } - match migration::apply_diff( - target_dir, - diff, - name.clone(), - world_address, - account, - config.ui(), - None, - ) - .await + + match migration::apply_diff(ws, target_dir, diff, name.clone(), world_address, account, None) + .await { Ok(address) => { config @@ -202,18 +192,16 @@ impl DevArgs { let name = self.name.clone(); let mut previous_manifest: Option = Option::None; let result = build(&mut context); - let env_metadata = context.dojo_metadata.as_ref().and_then(|e| e.env.clone()); let Some((mut world_address, account)) = context .ws .config() .tokio_handle() .block_on(migration::setup_env( + &context.ws, self.account, self.starknet, self.world, - env_metadata.as_ref(), - config.ui(), name.as_ref(), )) .ok() diff --git a/crates/sozo/src/commands/migrate.rs b/crates/sozo/src/commands/migrate.rs index b56ccf3a52..4e20cee5c2 100644 --- a/crates/sozo/src/commands/migrate.rs +++ b/crates/sozo/src/commands/migrate.rs @@ -1,6 +1,5 @@ use anyhow::Result; use clap::Args; -use dojo_world::metadata::dojo_metadata_from_workspace; use scarb::core::Config; use super::options::account::AccountOptions; @@ -53,15 +52,9 @@ impl MigrateArgs { scarb::ops::compile(packages, &ws)?; } - let env_metadata = dojo_metadata_from_workspace(&ws).and_then(|inner| inner.env().cloned()); // TODO: Check the updated scarb way to read profile specific values - ws.config().tokio_handle().block_on(migration::execute( - self, - env_metadata, - target_dir, - ws.config().ui(), - ))?; + ws.config().tokio_handle().block_on(migration::execute(&ws, self, target_dir))?; Ok(()) } diff --git a/crates/sozo/src/ops/migration/migration_test.rs b/crates/sozo/src/ops/migration/migration_test.rs index b8f9a6acd4..b21c459bb6 100644 --- a/crates/sozo/src/ops/migration/migration_test.rs +++ b/crates/sozo/src/ops/migration/migration_test.rs @@ -1,4 +1,5 @@ use camino::Utf8PathBuf; +use dojo_test_utils::compiler::build_test_config; use dojo_test_utils::migration::prepare_migration; use dojo_test_utils::sequencer::{ get_default_test_starknet_config, SequencerConfig, StarknetConfig, TestSequencer, @@ -6,7 +7,7 @@ use dojo_test_utils::sequencer::{ use dojo_world::manifest::Manifest; use dojo_world::migration::strategy::prepare_for_migration; use dojo_world::migration::world::WorldDiff; -use scarb_ui::{OutputFormat, Ui, Verbosity}; +use scarb::ops; use starknet::accounts::{ExecutionEncoding, SingleOwnerAccount}; use starknet::core::chain_id; use starknet::core::types::{BlockId, BlockTag}; @@ -20,8 +21,11 @@ use crate::ops::migration::execute_strategy; #[tokio::test(flavor = "multi_thread")] async fn migrate_with_auto_mine() { - let ui = Ui::new(Verbosity::Verbose, OutputFormat::Text); - let migration = prepare_migration("../../examples/ecs/target/dev".into()).unwrap(); + let config = build_test_config("../../examples/ecs/Scarb.toml").unwrap(); + let ws = ops::read_workspace(config.manifest_path(), &config) + .unwrap_or_else(|op| panic!("Error building workspace: {op:?}")); + + let mut migration = prepare_migration("../../examples/ecs/target/dev".into()).unwrap(); let sequencer = TestSequencer::start(SequencerConfig::default(), get_default_test_starknet_config()).await; @@ -29,15 +33,18 @@ async fn migrate_with_auto_mine() { let mut account = sequencer.account(); account.set_block_id(BlockId::Tag(BlockTag::Pending)); - execute_strategy(&migration, &account, &ui, None).await.unwrap(); + execute_strategy(&ws, &mut migration, &account, None).await.unwrap(); sequencer.stop().unwrap(); } #[tokio::test(flavor = "multi_thread")] async fn migrate_with_block_time() { - let ui = Ui::new(Verbosity::Verbose, OutputFormat::Text); - let migration = prepare_migration("../../examples/ecs/target/dev".into()).unwrap(); + let config = build_test_config("../../examples/ecs/Scarb.toml").unwrap(); + let ws = ops::read_workspace(config.manifest_path(), &config) + .unwrap_or_else(|op| panic!("Error building workspace: {op:?}")); + + let mut migration = prepare_migration("../../examples/ecs/target/dev".into()).unwrap(); let sequencer = TestSequencer::start( SequencerConfig { block_time: Some(1000), ..Default::default() }, @@ -48,14 +55,17 @@ async fn migrate_with_block_time() { let mut account = sequencer.account(); account.set_block_id(BlockId::Tag(BlockTag::Pending)); - execute_strategy(&migration, &account, &ui, None).await.unwrap(); + execute_strategy(&ws, &mut migration, &account, None).await.unwrap(); sequencer.stop().unwrap(); } #[tokio::test(flavor = "multi_thread")] async fn migrate_with_small_fee_multiplier_will_fail() { - let ui = Ui::new(Verbosity::Verbose, OutputFormat::Text); - let migration = prepare_migration("../../examples/ecs/target/dev".into()).unwrap(); + let config = build_test_config("../../examples/ecs/Scarb.toml").unwrap(); + let ws = ops::read_workspace(config.manifest_path(), &config) + .unwrap_or_else(|op| panic!("Error building workspace: {op:?}")); + + let mut migration = prepare_migration("../../examples/ecs/target/dev".into()).unwrap(); let sequencer = TestSequencer::start( Default::default(), @@ -75,9 +85,9 @@ async fn migrate_with_small_fee_multiplier_will_fail() { assert!( execute_strategy( - &migration, + &ws, + &mut migration, &account, - &ui, Some(TransactionOptions { fee_estimate_multiplier: Some(0.2f64) }), ) .await @@ -98,7 +108,9 @@ fn migrate_world_without_seed_will_fail() { #[ignore] #[tokio::test] async fn migration_from_remote() { - let ui = Ui::new(Verbosity::Verbose, OutputFormat::Text); + let config = build_test_config("../../examples/ecs/Scarb.toml").unwrap(); + let ws = ops::read_workspace(config.manifest_path(), &config) + .unwrap_or_else(|op| panic!("Error building workspace: {op:?}")); let target_dir = Utf8PathBuf::from_path_buf("../../examples/ecs/target/dev".into()).unwrap(); let sequencer = @@ -114,13 +126,13 @@ async fn migration_from_remote() { ExecutionEncoding::Legacy, ); - let manifest = Manifest::load_from_path(target_dir.clone()).unwrap(); + let manifest = Manifest::load_from_path(target_dir.join("manifest.json")).unwrap(); let world = WorldDiff::compute(manifest, None); - let migration = + let mut migration = prepare_for_migration(None, Some(felt!("0x12345")), target_dir.clone(), world).unwrap(); - execute_strategy(&migration, &account, &ui, None).await.unwrap(); + execute_strategy(&ws, &mut migration, &account, None).await.unwrap(); let local_manifest = Manifest::load_from_path(target_dir.join("manifest.json")).unwrap(); let remote_manifest = Manifest::from_remote( diff --git a/crates/sozo/src/ops/migration/mod.rs b/crates/sozo/src/ops/migration/mod.rs index 0be15b4a89..e32ae84e88 100644 --- a/crates/sozo/src/ops/migration/mod.rs +++ b/crates/sozo/src/ops/migration/mod.rs @@ -2,7 +2,7 @@ use std::path::Path; use anyhow::{anyhow, bail, Context, Result}; use dojo_world::manifest::{Manifest, ManifestError}; -use dojo_world::metadata::Environment; +use dojo_world::metadata::dojo_metadata_from_workspace; use dojo_world::migration::contract::ContractMigration; use dojo_world::migration::strategy::{prepare_for_migration, MigrationStrategy}; use dojo_world::migration::world::WorldDiff; @@ -10,12 +10,13 @@ use dojo_world::migration::{ Declarable, DeployOutput, Deployable, MigrationError, RegisterOutput, StateDiff, }; use dojo_world::utils::TransactionWaiter; +use scarb::core::Workspace; use scarb_ui::Ui; use starknet::accounts::{Account, ConnectedAccount, SingleOwnerAccount}; use starknet::core::types::{ BlockId, BlockTag, FieldElement, InvokeTransactionResult, StarknetError, }; -use starknet::core::utils::cairo_short_string_to_felt; +use starknet::core::utils::{cairo_short_string_to_felt, get_contract_address}; use starknet::providers::jsonrpc::HttpTransport; use torii_client::contract::world::WorldContract; @@ -37,21 +38,16 @@ use crate::commands::options::starknet::StarknetOptions; use crate::commands::options::transaction::TransactionOptions; use crate::commands::options::world::WorldOptions; -pub async fn execute( - args: MigrateArgs, - env_metadata: Option, - target_dir: U, - ui: &Ui, -) -> Result<()> +pub async fn execute(ws: &Workspace<'_>, args: MigrateArgs, target_dir: U) -> Result<()> where U: AsRef, { + let ui = ws.config().ui(); let MigrateArgs { account, starknet, world, name, .. } = args; // Setup account for migration and fetch world address if it exists. - let (world_address, account) = - setup_env(account, starknet, world, env_metadata.as_ref(), ui, name.as_ref()).await?; + let (world_address, account) = setup_env(ws, account, starknet, world, name.as_ref()).await?; // Load local and remote World manifests. @@ -69,20 +65,21 @@ where ui.print("\n✨ No changes to be made. Remote World is already up to date!") } else { // Mirate according to the diff. - apply_diff(target_dir, diff, name, world_address, &account, ui, Some(args.transaction)) + apply_diff(ws, target_dir, diff, name, world_address, &account, Some(args.transaction)) .await?; } Ok(()) } +#[allow(clippy::too_many_arguments)] pub(crate) async fn apply_diff( + ws: &Workspace<'_>, target_dir: U, diff: WorldDiff, name: Option, world_address: Option, account: &SingleOwnerAccount, - ui: &Ui, txn_config: Option, ) -> Result where @@ -90,11 +87,12 @@ where P: Provider + Sync + Send + 'static, S: Signer + Sync + Send + 'static, { - let strategy = prepare_migration(target_dir, diff, name, world_address, ui)?; + let ui = ws.config().ui(); + let mut strategy = prepare_migration(target_dir, diff, name, world_address, ui)?; println!(" "); - let block_height = execute_strategy(&strategy, account, ui, txn_config) + let block_height = execute_strategy(ws, &mut strategy, account, txn_config) .await .map_err(|e| anyhow!(e)) .with_context(|| "Problem trying to migrate.")?; @@ -122,18 +120,21 @@ where } pub(crate) async fn setup_env( + ws: &Workspace<'_>, account: AccountOptions, starknet: StarknetOptions, world: WorldOptions, - env_metadata: Option<&Environment>, - ui: &Ui, name: Option<&String>, ) -> Result<(Option, SingleOwnerAccount, LocalWallet>)> { - let world_address = world.address(env_metadata).ok(); + let ui = ws.config().ui(); + let metadata = dojo_metadata_from_workspace(ws); + let env = metadata.as_ref().and_then(|inner| inner.env()); + + let world_address = world.address(env).ok(); let account = { - let provider = starknet.provider(env_metadata)?; - let mut account = account.account(provider, env_metadata).await?; + let provider = starknet.provider(env)?; + let mut account = account.account(provider, env).await?; account.set_block_id(BlockId::Tag(BlockTag::Pending)); let address = account.address(); @@ -240,20 +241,23 @@ where // returns the Some(block number) at which migration world is deployed, returns none if world was // not redeployed pub async fn execute_strategy( - strategy: &MigrationStrategy, + ws: &Workspace<'_>, + strategy: &mut MigrationStrategy, migrator: &SingleOwnerAccount, - ui: &Ui, txn_config: Option, ) -> Result> where P: Provider + Sync + Send + 'static, S: Signer + Sync + Send + 'static, { + let ui = ws.config().ui(); + match &strategy.executor { Some(executor) => { ui.print_header("# Executor"); deploy_contract(executor, "executor", vec![], migrator, ui, &txn_config).await?; + // There is no world migration, so it exists already. if strategy.world.is_none() { let addr = strategy.world_address()?; let InvokeTransactionResult { transaction_hash } = @@ -291,16 +295,43 @@ where None => {} }; - match &strategy.world { + match &mut strategy.world { Some(world) => { ui.print_header("# World"); - let calldata = vec![ + let metadata = dojo_metadata_from_workspace(ws); + + let metadata_uri = if let Some(meta) = metadata.as_ref().and_then(|inner| inner.world()) + { + let hash = meta.upload().await?; + let mut parts = format!("ipfs://{hash}") + .chars() + .collect::>() + .chunks(31) + .map(|chunk| { + let s: String = chunk.iter().collect(); + cairo_short_string_to_felt(&s).unwrap() + }) + .collect::>(); + + ui.print_sub(format!("Metadata uri: ipfs://{hash}")); + + parts.insert(0, parts.len().into()); + + parts + } else { + vec![FieldElement::ZERO] + }; + + let mut calldata = vec![ strategy.executor.as_ref().unwrap().contract_address, strategy.base.as_ref().unwrap().diff.local, - FieldElement::ZERO, ]; - deploy_contract(world, "world", calldata, migrator, ui, &txn_config).await?; + calldata.extend(metadata_uri); + deploy_contract(world, "world", calldata.clone(), migrator, ui, &txn_config).await?; + + world.contract_address = + get_contract_address(world.salt, world.diff.local, &calldata, FieldElement::ZERO); ui.print_sub(format!("Contract address: {:#x}", world.contract_address)); } diff --git a/crates/torii/core/Cargo.toml b/crates/torii/core/Cargo.toml index 930d4c550c..13874ac66f 100644 --- a/crates/torii/core/Cargo.toml +++ b/crates/torii/core/Cargo.toml @@ -38,4 +38,5 @@ tracing.workspace = true [dev-dependencies] camino.workspace = true dojo-test-utils = { path = "../../dojo-test-utils" } +scarb.workspace = true sozo = { path = "../../sozo" } diff --git a/crates/torii/core/src/sql_test.rs b/crates/torii/core/src/sql_test.rs index c734d0a35e..7b1677d669 100644 --- a/crates/torii/core/src/sql_test.rs +++ b/crates/torii/core/src/sql_test.rs @@ -1,9 +1,10 @@ +use dojo_test_utils::compiler::build_test_config; use dojo_test_utils::migration::prepare_migration; use dojo_test_utils::sequencer::{ get_default_test_starknet_config, SequencerConfig, TestSequencer, }; use dojo_world::migration::strategy::MigrationStrategy; -use scarb_ui::{OutputFormat, Ui, Verbosity}; +use scarb::ops; use sozo::ops::migration::execute_strategy; use sqlx::sqlite::SqlitePoolOptions; use starknet::core::types::{BlockId, BlockTag, Event, FieldElement}; @@ -21,14 +22,16 @@ pub async fn bootstrap_engine<'a>( world: &'a WorldContractReader<'a, JsonRpcClient>, db: &'a mut Sql, provider: &'a JsonRpcClient, - migration: &MigrationStrategy, + migration: &mut MigrationStrategy, sequencer: &TestSequencer, ) -> Result>, Box> { let mut account = sequencer.account(); account.set_block_id(BlockId::Tag(BlockTag::Pending)); - let ui = Ui::new(Verbosity::Verbose, OutputFormat::Text); - execute_strategy(migration, &account, &ui, None).await.unwrap(); + let config = build_test_config("../../../examples/ecs/Scarb.toml").unwrap(); + let ws = ops::read_workspace(config.manifest_path(), &config) + .unwrap_or_else(|op| panic!("Error building workspace: {op:?}")); + execute_strategy(&ws, migration, &account, None).await.unwrap(); let mut engine = Engine::new( world, @@ -56,14 +59,14 @@ async fn test_load_from_remote() { let pool = SqlitePoolOptions::new().max_connections(5).connect("sqlite::memory:").await.unwrap(); sqlx::migrate!("../migrations").run(&pool).await.unwrap(); - let migration = prepare_migration("../../../examples/ecs/target/dev".into()).unwrap(); + let mut migration = prepare_migration("../../../examples/ecs/target/dev".into()).unwrap(); let sequencer = TestSequencer::start(SequencerConfig::default(), get_default_test_starknet_config()).await; let provider = JsonRpcClient::new(HttpTransport::new(sequencer.url())); let world = WorldContractReader::new(migration.world_address().unwrap(), &provider); let mut db = Sql::new(pool.clone(), migration.world_address().unwrap()).await.unwrap(); - let _ = bootstrap_engine(&world, &mut db, &provider, &migration, &sequencer).await; + let _ = bootstrap_engine(&world, &mut db, &provider, &mut migration, &sequencer).await; let models = sqlx::query("SELECT * FROM models").fetch_all(&pool).await.unwrap(); assert_eq!(models.len(), 2); diff --git a/examples/ecs/Scarb.toml b/examples/ecs/Scarb.toml index f0ce236efc..ad6b5299d2 100644 --- a/examples/ecs/Scarb.toml +++ b/examples/ecs/Scarb.toml @@ -16,6 +16,8 @@ build-external-contracts = [] [tool.dojo.world] name = "example" description = "example world" +icon_path = "assets/icon.png" +cover_path = "assets/cover.png" [tool.dojo.env] rpc_url = "http://localhost:5050/" diff --git a/examples/ecs/assets/cover.png b/examples/ecs/assets/cover.png new file mode 100644 index 0000000000000000000000000000000000000000..8ac043f479414ef58a3cdba11e5fc8ca080ce67e GIT binary patch literal 19098 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 z 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 By|@4X literal 0 HcmV?d00001