diff --git a/Cargo.lock b/Cargo.lock index c07a009fa9..8433ed9db4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2249,7 +2249,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40723b8fb387abc38f4f4a37c09073622e41dd12327033091ef8950659e6dc0c" dependencies = [ "memchr", - "regex-automata 0.4.7", + "regex-automata 0.4.9", "serde", ] @@ -2800,11 +2800,29 @@ name = "cairo-lang-macro" version = "0.1.0" source = "git+https://github.com/dojoengine/scarb?rev=7eac49b3e61236ce466e712225d9c989f9db1ef3#7eac49b3e61236ce466e712225d9c989f9db1ef3" dependencies = [ - "cairo-lang-macro-attributes", + "cairo-lang-macro-attributes 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "cairo-lang-macro-stable", + "linkme", +] + +[[package]] +name = "cairo-lang-macro" +version = "0.1.1" +dependencies = [ + "cairo-lang-macro-attributes 0.1.0", "cairo-lang-macro-stable", "linkme", ] +[[package]] +name = "cairo-lang-macro-attributes" +version = "0.1.0" +dependencies = [ + "quote", + "scarb-stable-hash 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "syn 2.0.77", +] + [[package]] name = "cairo-lang-macro-attributes" version = "0.1.0" @@ -4804,7 +4822,26 @@ version = "1.0.1" dependencies = [ "cairo-lang-language-server", "clap", - "dojo-lang 1.0.1", + "dojo-macros", +] + +[[package]] +name = "dojo-macros" +version = "0.1.0" +dependencies = [ + "cairo-lang-defs", + "cairo-lang-macro 0.1.1", + "cairo-lang-parser", + "cairo-lang-plugins", + "cairo-lang-syntax", + "cairo-lang-utils", + "convert_case 0.6.0", + "dojo-types 1.0.1", + "regex", + "serde", + "serde_json", + "starknet 0.12.0", + "starknet-crypto 0.7.2", ] [[package]] @@ -6556,8 +6593,8 @@ dependencies = [ "aho-corasick", "bstr", "log", - "regex-automata 0.4.7", - "regex-syntax 0.8.4", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", ] [[package]] @@ -7421,7 +7458,7 @@ dependencies = [ "globset", "log", "memchr", - "regex-automata 0.4.7", + "regex-automata 0.4.9", "same-file", "walkdir", "winapi-util", @@ -8784,7 +8821,7 @@ dependencies = [ "petgraph", "pico-args", "regex", - "regex-syntax 0.8.4", + "regex-syntax 0.8.5", "string_cache", "term", "tiny-keccak", @@ -8798,7 +8835,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" dependencies = [ - "regex-automata 0.4.7", + "regex-automata 0.4.9", ] [[package]] @@ -11280,7 +11317,7 @@ dependencies = [ "rand 0.8.5", "rand_chacha", "rand_xorshift", - "regex-syntax 0.8.4", + "regex-syntax 0.8.5", "rusty-fork", "tempfile", "unarray", @@ -11830,14 +11867,14 @@ dependencies = [ [[package]] name = "regex" -version = "1.10.6" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.7", - "regex-syntax 0.8.4", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", ] [[package]] @@ -11851,13 +11888,13 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.7" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.4", + "regex-syntax 0.8.5", ] [[package]] @@ -11868,9 +11905,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "relative-path" @@ -12777,7 +12814,7 @@ dependencies = [ "cairo-lang-filesystem", "cairo-lang-formatter", "cairo-lang-lowering", - "cairo-lang-macro", + "cairo-lang-macro 0.1.0", "cairo-lang-macro-stable", "cairo-lang-parser", "cairo-lang-semantic", @@ -13671,7 +13708,6 @@ dependencies = [ "clap-verbosity-flag", "colored", "dojo-bindgen", - "dojo-lang 1.0.1", "dojo-test-utils", "dojo-types 1.0.1", "dojo-utils", diff --git a/Cargo.toml b/Cargo.toml index 43968de1b8..b4b78ca9e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,9 @@ members = [ "bin/torii", "crates/dojo/bindgen", "crates/dojo/core", +# TODO: to be removed but still used by some tools like LS "crates/dojo/lang", + "crates/dojo/macros", "crates/dojo/test-utils", "crates/dojo/types", "crates/dojo/utils", @@ -77,7 +79,7 @@ dojo-metrics = { path = "crates/metrics" } # dojo-lang dojo-bindgen = { path = "crates/dojo/bindgen" } dojo-core = { path = "crates/dojo/core" } -dojo-lang = { path = "crates/dojo/lang" } +dojo-macros = { path = "crates/dojo/macros" } dojo-test-utils = { path = "crates/dojo/test-utils" } dojo-types = { path = "crates/dojo/types" } dojo-world = { path = "crates/dojo/world" } diff --git a/bin/dojo-language-server/Cargo.toml b/bin/dojo-language-server/Cargo.toml index 8dc16e26b0..2da861cc6e 100644 --- a/bin/dojo-language-server/Cargo.toml +++ b/bin/dojo-language-server/Cargo.toml @@ -8,4 +8,4 @@ version.workspace = true [dependencies] cairo-lang-language-server.workspace = true clap.workspace = true -dojo-lang.workspace = true +dojo-macros.workspace = true diff --git a/bin/dojo-language-server/src/main.rs b/bin/dojo-language-server/src/main.rs index 7f12f22078..9d2dba72d5 100644 --- a/bin/dojo-language-server/src/main.rs +++ b/bin/dojo-language-server/src/main.rs @@ -1,6 +1,4 @@ -use cairo_lang_language_server::Tricks; use clap::Parser; -use dojo_lang::dojo_plugin_suite; /// Dojo Language Server #[derive(Parser, Debug)] @@ -8,9 +6,5 @@ use dojo_lang::dojo_plugin_suite; struct Args {} fn main() { - let _args = Args::parse(); - - let mut tricks = Tricks::default(); - tricks.extra_plugin_suites = Some(&|| vec![dojo_plugin_suite()]); - cairo_lang_language_server::start_with_tricks(tricks); + cairo_lang_language_server::start(); } diff --git a/bin/sozo/Cargo.toml b/bin/sozo/Cargo.toml index 6831fbf059..d200b5073a 100644 --- a/bin/sozo/Cargo.toml +++ b/bin/sozo/Cargo.toml @@ -23,7 +23,6 @@ clap.workspace = true clap-verbosity-flag.workspace = true colored.workspace = true dojo-bindgen.workspace = true -dojo-lang.workspace = true dojo-types.workspace = true dojo-utils.workspace = true dojo-world.workspace = true diff --git a/bin/sozo/src/commands/test.rs b/bin/sozo/src/commands/test.rs index 772a87df1a..6c18f7f213 100644 --- a/bin/sozo/src/commands/test.rs +++ b/bin/sozo/src/commands/test.rs @@ -16,7 +16,6 @@ use cairo_lang_test_plugin::{test_plugin_suite, TestsCompilationConfig}; use cairo_lang_test_runner::{CompiledTestRunner, RunProfilerConfig, TestCompiler, TestRunConfig}; use cairo_lang_utils::ordered_hash_map::OrderedHashMap; use clap::Args; -use dojo_lang::dojo_plugin_suite; use itertools::Itertools; use scarb::compiler::{ CairoCompilationUnit, CompilationUnit, CompilationUnitAttributes, ContractSelector, @@ -197,7 +196,6 @@ pub(crate) fn build_root_database(unit: &CairoCompilationUnit) -> Result::layout(); } @@ -304,7 +304,7 @@ fn test_layout_of_inner_packed_enum() { } #[test] -#[should_panic(expected: ("A packed model layout must contain Fixed layouts only.",))] +#[should_panic(expected: "A packed model layout must contain Fixed layouts only.")] fn test_layout_of_not_packed_inner_enum() { let _ = Introspect::::layout(); } diff --git a/crates/dojo/core-foundry-test/.snfoundry_cache/.prev_tests_failed b/crates/dojo/core-foundry-test/.snfoundry_cache/.prev_tests_failed new file mode 100644 index 0000000000..a2443dfcb1 --- /dev/null +++ b/crates/dojo/core-foundry-test/.snfoundry_cache/.prev_tests_failed @@ -0,0 +1,91 @@ +dojo_cairo_test::tests::contract::test_register_namespace_empty_name +dojo_cairo_test::tests::contract::test_upgrade_direct +dojo_cairo_test::tests::world::acl::test_grant_writer_through_malicious_contract +dojo_cairo_test::tests::contract::test_upgrade_from_world +dojo_cairo_test::tests::world::acl::test_grant_writer_fails_for_non_owner +dojo_cairo_test::tests::world::acl::test_not_writer_with_known_contract +dojo_cairo_test::tests::contract::test_upgrade_from_world_not_world_provider +dojo_cairo_test::tests::world::acl::test_owner +dojo_cairo_test::tests::world::acl::test_register_contract_namespace_not_owner +dojo_cairo_test::tests::world::acl::test_register_model_namespace_not_owner +dojo_cairo_test::tests::world::acl::test_writer +dojo_cairo_test::tests::world::contract::test_deploy_contract_for_namespace_owner +dojo_cairo_test::tests::world::acl::test_writer_not_registered_resource +dojo_cairo_test::tests::world::contract::test_deploy_contract_for_namespace_writer +dojo_cairo_test::tests::world::acl::test_revoke_owner_fails_for_non_owner +dojo_cairo_test::tests::world::contract::test_deploy_contract_through_malicious_contract +dojo_cairo_test::tests::world::contract::test_deploy_contract_no_namespace_owner_access +dojo_cairo_test::tests::world::contract::test_deploy_contract_with_unregistered_namespace +dojo_cairo_test::tests::world::acl::test_revoke_owner_through_malicious_contract +dojo_cairo_test::tests::world::acl::test_revoke_writer_fails_for_non_owner +dojo_cairo_test::tests::world::contract::test_upgrade_contract_from_random_account +dojo_cairo_test::tests::world::event::test_register_event_with_unregistered_namespace +dojo_cairo_test::tests::world::contract::test_upgrade_contract_from_resource_writer +dojo_cairo_test::tests::world::contract::test_upgrade_contract_from_resource_owner +dojo_cairo_test::tests::world::acl::test_revoke_writer_through_malicious_contract +dojo_cairo_test::tests::world::contract::test_upgrade_contract_through_malicious_contract +dojo_cairo_test::tests::world::event::test_upgrade_event +dojo_cairo_test::tests::world::event::test_upgrade_event_from_event_writer +dojo_cairo_test::tests::world::event::test_upgrade_event_from_event_owner +dojo_cairo_test::tests::world::event::test_upgrade_event_from_random_account +dojo_cairo_test::tests::world::contract::test_upgrade_direct +dojo_cairo_test::tests::world::event::test_upgrade_event_with_bad_layout_type +dojo_cairo_test::tests::world::contract::test_upgrade_from_world +dojo_cairo_test::tests::world::event::test_upgrade_event_with_member_added_but_removed +dojo_cairo_test::tests::world::contract::test_upgrade_from_world_not_world_provider +dojo_cairo_test::tests::world::event::test_upgrade_event_with_member_moved +dojo_cairo_test::tests::world::event::test_register_event_for_namespace_owner +dojo_cairo_test::tests::world::event::test_register_event_for_namespace_writer +dojo_cairo_test::tests::world::metadata::test_set_metadata_not_possible_for_random_account +dojo_cairo_test::tests::world::event::test_upgrade_event_with_member_removed +dojo_cairo_test::tests::world::event::test_register_event_through_malicious_contract +dojo_cairo_test::tests::world::model::test_register_model_for_namespace_writer +dojo_cairo_test::tests::world::metadata::test_set_metadata_world +dojo_cairo_test::tests::world::model::test_register_model_through_malicious_contract +dojo_cairo_test::tests::world::model::test_register_model_for_namespace_owner +dojo_cairo_test::tests::world::model::test_register_model_with_invalid_name +dojo_cairo_test::tests::world::model::test_register_model_with_unregistered_namespace +dojo_cairo_test::tests::world::model::test_upgrade_model_from_model_owner +dojo_cairo_test::tests::world::model::test_upgrade_model +dojo_cairo_test::tests::world::metadata::test_set_metadata_not_possible_for_resource_writer +dojo_cairo_test::tests::world::model::test_upgrade_model_from_model_writer +dojo_cairo_test::tests::world::namespace::test_register_namespace +dojo_cairo_test::tests::world::model::test_upgrade_model_with_member_removed +dojo_cairo_test::tests::world::metadata::test_set_metadata_through_malicious_contract +dojo_cairo_test::tests::world::metadata::test_set_metadata_resource_owner +dojo_cairo_test::tests::world::namespace::test_register_namespace_already_registered_other_caller +dojo_cairo_test::tests::world::model::test_upgrade_model_with_bad_layout_type +dojo_cairo_test::tests::world::model::test_upgrade_model_from_random_account +dojo_cairo_test::tests::world::storage::write_multiple_not_copiable +dojo_cairo_test::tests::world::model::test_upgrade_model_with_member_added_but_removed +dojo_cairo_test::tests::world::world::test_can_call_init_only_world +dojo_cairo_test::tests::world::model::test_upgrade_model_with_member_moved +dojo_cairo_test::tests::world::world::test_can_call_init_only_world_args +dojo_cairo_test::tests::world::world::test_contract_getter +dojo_cairo_test::tests::world::world::test_delete +dojo_cairo_test::tests::world::world::test_constructor_default +dojo_cairo_test::tests::world::world::test_emit +dojo_cairo_test::tests::world::world::test_model +dojo_cairo_test::tests::world::world::test_system +dojo_cairo_test::tests::world::world::test_execute_multiple_worlds +dojo_cairo_test::tests::world::world::test_upgradeable_world_from_non_owner +dojo_cairo_test::tests::world::world::test_upgradeable_world_with_class_hash_zero +dojo_cairo_test::tests::world::namespace::test_register_namespace_empty_name +dojo_cairo_test::tests::world::world::test_can_call_init_args +dojo_cairo_test::tests::world::world::test_upgradeable_world +dojo_cairo_test::tests::model::model::test_model_ptr_from_entity_id +dojo_cairo_test::tests::world::world::test_can_call_init_only_owner +dojo_cairo_test::tests::world::storage::write_multiple_copiable +dojo_cairo_test::tests::model::model::test_delete_from_model +dojo_cairo_test::tests::model::model::test_model_ptr_from_keys +dojo_cairo_test::tests::model::model::test_read_and_write_field_name +dojo_cairo_test::tests::model::model::test_model_ptr_from_serialized_keys +dojo_cairo_test::tests::model::model::test_read_and_update_model_value +dojo_cairo_test::tests::model::model::test_delete_model_value +dojo_cairo_test::tests::model::model::test_read_and_write_from_model +dojo_cairo_test::tests::world::acl::test_grant_owner_fails_for_non_owner +dojo_cairo_test::tests::world::acl::test_grant_owner_not_registered_resource +dojo_cairo_test::tests::world::namespace::test_register_namespace_already_registered_same_caller +dojo_cairo_test::tests::world::acl::test_grant_owner_through_malicious_contract +dojo_cairo_test::tests::world::world::test_can_call_init_default +dojo_cairo_test::tests::world::storage::write_simple diff --git a/crates/dojo/core-foundry-test/Scarb.lock b/crates/dojo/core-foundry-test/Scarb.lock new file mode 100644 index 0000000000..b7b28a53ef --- /dev/null +++ b/crates/dojo/core-foundry-test/Scarb.lock @@ -0,0 +1,34 @@ +# Code generated by scarb DO NOT EDIT. +version = 1 + +[[package]] +name = "dojo" +version = "1.0.1" +dependencies = [ + "dojo_macros", +] + +[[package]] +name = "dojo_cairo_test" +version = "1.0.0-rc.0" +dependencies = [ + "dojo", + "snforge_std", +] + +[[package]] +name = "dojo_macros" +version = "0.1.0" + +[[package]] +name = "snforge_scarb_plugin" +version = "0.33.0" +source = "git+https://github.com/foundry-rs/starknet-foundry.git?tag=v0.33.0#221b1dbff42d650e9855afd4283508da8f8cacba" + +[[package]] +name = "snforge_std" +version = "0.33.0" +source = "git+https://github.com/foundry-rs/starknet-foundry.git?tag=v0.33.0#221b1dbff42d650e9855afd4283508da8f8cacba" +dependencies = [ + "snforge_scarb_plugin", +] diff --git a/crates/dojo/core-foundry-test/Scarb.toml b/crates/dojo/core-foundry-test/Scarb.toml new file mode 100644 index 0000000000..5ad1f20596 --- /dev/null +++ b/crates/dojo/core-foundry-test/Scarb.toml @@ -0,0 +1,19 @@ +[package] +cairo-version = "=2.8.4" +edition = "2024_07" +description = "Testing library for Dojo using Cairo test runner." +name = "dojo_cairo_test" +version = "1.0.0-rc.0" + +[dependencies] +starknet = "=2.8.4" +dojo = { path = "../core" } + +[dev-dependencies] +snforge_std = { git = "https://github.com/foundry-rs/starknet-foundry.git", tag = "v0.33.0" } +assert_macros = "2.8.4" + +[scripts] +test = "snforge test" + +[lib] diff --git a/crates/dojo/core-foundry-test/src/lib.cairo b/crates/dojo/core-foundry-test/src/lib.cairo new file mode 100644 index 0000000000..3a64fc4121 --- /dev/null +++ b/crates/dojo/core-foundry-test/src/lib.cairo @@ -0,0 +1,79 @@ +#[cfg(target: "test")] +mod utils; +#[cfg(target: "test")] +mod world; + +#[cfg(target: "test")] +pub use utils::{GasCounter, assert_array, GasCounterTrait}; +#[cfg(target: "test")] +pub use world::{ + deploy_contract, deploy_with_world_address, spawn_test_world, NamespaceDef, TestResource, + ContractDef, ContractDefTrait, WorldStorageTestTrait, +}; + +#[cfg(test)] +mod tests { + mod meta { + mod introspect; + } + + mod event { + mod event; + } + + mod model { + mod model; + } + + mod storage { + mod database; + mod packing; + mod storage; + } + + mod contract; + // mod benchmarks; + + mod expanded { + pub(crate) mod selector_attack; + } + + mod helpers { + mod helpers; + pub use helpers::{ + DOJO_NSH, SimpleEvent, e_SimpleEvent, Foo, m_Foo, foo_invalid_name, foo_setter, + test_contract, test_contract_with_dojo_init_args, Sword, Case, Character, Abilities, + Stats, Weapon, Ibar, IbarDispatcher, IbarDispatcherTrait, bar, deploy_world, + deploy_world_and_bar, deploy_world_and_foo, drop_all_events, IFooSetter, + IFooSetterDispatcher, IFooSetterDispatcherTrait, NotCopiable + }; + + mod event; + pub use event::{ + FooEventBadLayoutType, e_FooEventBadLayoutType, deploy_world_for_event_upgrades + }; + + mod model; + pub use model::deploy_world_for_model_upgrades; + } + + mod world { + mod acl; + mod contract; + //mod entities; + mod event; + mod metadata; + mod model; + mod namespace; + mod storage; + mod world; + } + + mod utils { + mod hash; + mod key; + mod layout; + mod misc; + mod naming; + } +} diff --git a/crates/dojo/core-foundry-test/src/tests/benchmarks.cairo b/crates/dojo/core-foundry-test/src/tests/benchmarks.cairo new file mode 100644 index 0000000000..e76b91b479 --- /dev/null +++ b/crates/dojo/core-foundry-test/src/tests/benchmarks.cairo @@ -0,0 +1,520 @@ +use core::array::{ArrayTrait, SpanTrait}; +use core::poseidon::poseidon_hash_span; +use core::result::ResultTrait; +use core::serde::Serde; + +use starknet::{ContractAddress, SyscallResultTrait}; +use starknet::storage_access::{ + storage_base_address_from_felt252, storage_address_from_base, + storage_address_from_base_and_offset +}; +use starknet::syscalls::{storage_read_syscall, storage_write_syscall}; + +use dojo::meta::Layout; +use dojo::model::{Model, ModelIndex, ModelStore}; +use dojo::storage::{database, storage}; +use dojo::world::{IWorldDispatcher, IWorldDispatcherTrait}; + +use crate::tests::helpers::{Foo, Sword, Case, case, Character, Abilities, Stats, Weapon, DOJO_NSH}; +use crate::world::{spawn_test_world, NamespaceDef, TestResource}; +use crate::utils::GasCounterTrait; + +#[derive(Drop, Serde)] +#[dojo::model] +struct CaseNotPacked { + #[key] + pub owner: ContractAddress, + pub sword: Sword, + pub material: felt252, +} + +#[derive(Drop, Serde)] +#[dojo::model] +struct ComplexModel { + #[key] + pub game_id: u128, + #[key] + pub player: ContractAddress, + pub first_name: ByteArray, + pub last_name: ByteArray, + pub weapons: Array, + pub abilities: (Abilities, Abilities), + pub stats: Stats, +} + +fn deploy_world() -> IWorldDispatcher { + let namespace_def = NamespaceDef { + namespace: "dojo", resources: [ + TestResource::Model(case::TEST_CLASS_HASH.try_into().unwrap()), + TestResource::Model(case_not_packed::TEST_CLASS_HASH.try_into().unwrap()), + TestResource::Model(complex_model::TEST_CLASS_HASH.try_into().unwrap()), + ].span(), + }; + + spawn_test_world([namespace_def].span()) +} + +#[test] +#[available_gas(1000000000)] +fn bench_reference_offset() { + let gas = GasCounterTrait::start(); + gas.end("bench empty"); +} + +#[test] +#[available_gas(1000000000)] +fn bench_storage_single() { + let keys = ['database_test', '42'].span(); + + let gas = GasCounterTrait::start(); + storage::set(0, keys, 420); + gas.end("storage set"); + + let gas = GasCounterTrait::start(); + let res = storage::get(0, keys); + gas.end("storage get"); + + assert(res == 420, 'values differ'); +} + +#[test] +#[available_gas(1000000000)] +fn bench_storage_many() { + let keys = [0x1337].span(); + let values = [1, 2].span(); + let layout = [251, 251].span(); + + let gas = GasCounterTrait::start(); + storage::set_many(0, keys, values, 0, layout).unwrap(); + gas.end("storage set_many"); + + let gas = GasCounterTrait::start(); + let res = storage::get_many(0, keys, layout).unwrap(); + gas.end("storage get_many"); + + assert(res.len() == 2, 'wrong number of values'); + assert(*res.at(0) == *values.at(0), 'value not set'); + assert(*res.at(1) == *values.at(1), 'value not set'); +} + +#[test] +#[available_gas(1000000000)] +fn bench_native_storage() { + let gas = GasCounterTrait::start(); + let keys = [0x1337].span(); + let base = storage_base_address_from_felt252(poseidon_hash_span(keys)); + let address = storage_address_from_base(base); + gas.end("native prep"); + + let gas = GasCounterTrait::start(); + storage_write_syscall(0, address, 42).unwrap_syscall(); + gas.end("native write"); + + let gas = GasCounterTrait::start(); + let value = storage_read_syscall(0, address).unwrap_syscall(); + gas.end("native read"); + + assert(value == 42, 'read invalid'); +} + +#[test] +#[available_gas(1000000000)] +fn bench_native_storage_offset() { + let gas = GasCounterTrait::start(); + let keys = [0x1337].span(); + let base = storage_base_address_from_felt252(poseidon_hash_span(keys)); + let address = storage_address_from_base_and_offset(base, 42); + gas.end("native prep of"); + + let gas = GasCounterTrait::start(); + storage_write_syscall(0, address, 42).unwrap_syscall(); + gas.end("native writ of"); + + let gas = GasCounterTrait::start(); + let value = storage_read_syscall(0, address).unwrap_syscall(); + gas.end("native read of"); + + assert(value == 42, 'read invalid'); +} + +#[test] +#[available_gas(1000000000)] +fn bench_database_array() { + let mut keys = ArrayTrait::new(); + keys.append(0x966); + + let array_test_len: usize = 300; + + let mut layout = ArrayTrait::new(); + let mut values: Array = ArrayTrait::new(); + let mut i = 0; + loop { + if i == array_test_len { + break; + } + + values.append(i.into()); + layout.append(251_u8); + + i += 1; + }; + + let gas = GasCounterTrait::start(); + database::set('table', 'key', values.span(), 0, layout.span()); + gas.end("db set arr"); + + let gas = GasCounterTrait::start(); + let res = database::get('table', 'key', layout.span()); + gas.end("db get arr"); + + let mut i = 0; + loop { + if i == array_test_len { + break; + } + + assert(res.at(i) == values.at(i), 'Value not equal!'); + i += 1; + }; +} + +#[test] +#[available_gas(1000000000)] +fn bench_simple_struct() { + let caller = starknet::contract_address_const::<0x42>(); + + let gas = GasCounterTrait::start(); + let mut foo = Foo { caller, a: 0x123456789abcdef, b: 0x123456789abcdef, }; + gas.end("foo init"); + + let gas = GasCounterTrait::start(); + let mut serialized = ArrayTrait::new(); + Serde::serialize(@foo.a, ref serialized); + Serde::serialize(@foo.b, ref serialized); + let serialized = ArrayTrait::span(@serialized); + gas.end("foo serialize"); + + let gas = GasCounterTrait::start(); + let values: Span = foo.serialized_values(); + gas.end("foo values"); + + assert(serialized.len() == 2, 'serialized wrong length'); + assert(values.len() == 2, 'value wrong length'); + assert(serialized.at(0) == values.at(0), 'serialized differ at 0'); + assert(serialized.at(1) == values.at(1), 'serialized differ at 1'); +} + +#[derive(Copy, Drop, Serde, IntrospectPacked)] +#[dojo::model] +struct PositionWithQuaterions { + #[key] + id: felt252, + x: felt252, + y: felt252, + z: felt252, + a: felt252, + b: felt252, + c: felt252, + d: felt252, +} + +// TODO: this test should be adapted to benchmark the new layout system +#[test] +#[available_gas(1000000000)] +fn test_struct_with_many_fields_fixed() { + let gas = GasCounterTrait::start(); + + let mut pos = PositionWithQuaterions { + id: 0x123456789abcdef, + x: 0x123456789abcdef, + y: 0x123456789abcdef, + z: 0x123456789abcdef, + a: 0x123456789abcdef, + b: 0x123456789abcdef, + c: 0x123456789abcdef, + d: 0x123456789abcdef, + }; + gas.end("pos init"); + + let gas = GasCounterTrait::start(); + let mut serialized = ArrayTrait::new(); + Serde::serialize(@pos.x, ref serialized); + Serde::serialize(@pos.y, ref serialized); + Serde::serialize(@pos.z, ref serialized); + Serde::serialize(@pos.a, ref serialized); + Serde::serialize(@pos.b, ref serialized); + Serde::serialize(@pos.c, ref serialized); + Serde::serialize(@pos.d, ref serialized); + let serialized = ArrayTrait::span(@serialized); + gas.end("pos serialize"); + + let gas = GasCounterTrait::start(); + let values: Span = pos.serialized_values(); + gas.end("pos values"); + + assert(serialized.len() == values.len(), 'serialized not equal'); + let mut idx = 0; + loop { + if idx == serialized.len() { + break; + } + assert(serialized.at(idx) == values.at(idx), 'serialized differ'); + idx += 1; + }; + + let layout = match dojo::model::Model::::layout() { + Layout::Fixed(layout) => layout, + _ => panic!("expected fixed layout"), + }; + + let gas = GasCounterTrait::start(); + database::set('positions', '42', pos.serialized_values(), 0, layout); + gas.end("pos db set"); + + let gas = GasCounterTrait::start(); + database::get('positions', '42', layout); + gas.end("pos db get"); +} + +// TODO: this test should be adapted to benchmark the new layout system +#[test] +#[ignore] +#[available_gas(1000000000)] +fn bench_nested_struct_packed() { + let caller = starknet::contract_address_const::<0x42>(); + + let gas = GasCounterTrait::start(); + let mut case = Case { + owner: caller, sword: Sword { swordsmith: caller, damage: 0x12345678, }, material: 'wooden', + }; + gas.end("case init"); + + let gas = GasCounterTrait::start(); + let mut serialized = ArrayTrait::new(); + Serde::serialize(@case.sword, ref serialized); + Serde::serialize(@case.material, ref serialized); + let serialized = ArrayTrait::span(@serialized); + gas.end("case serialize"); + + let gas = GasCounterTrait::start(); + let values: Span = case.serialized_values(); + gas.end("case values"); + + assert(serialized.len() == values.len(), 'serialized not equal'); + let mut idx = 0; + loop { + if idx == serialized.len() { + break; + } + assert(serialized.at(idx) == values.at(idx), 'serialized differ'); + idx += 1; + }; + + let layout = match dojo::model::Model::::layout() { + Layout::Fixed(layout) => layout, + _ => panic!("expected fixed layout"), + }; + + let gas = GasCounterTrait::start(); + database::set('cases', '42', values, 0, layout); + gas.end("case db set"); + + let gas = GasCounterTrait::start(); + database::get('cases', '42', layout); + gas.end("case db get"); +} + +// TODO: this test should be adapted to benchmark the new layout system +#[test] +#[ignore] +#[available_gas(1000000000)] +fn bench_complex_struct_packed() { + let gas = GasCounterTrait::start(); + + let char = Character { + caller: starknet::contract_address_const::<0x42>(), + heigth: 0x123456789abcdef, + abilities: Abilities { + strength: 0x12, + dexterity: 0x34, + constitution: 0x56, + intelligence: 0x78, + wisdom: 0x9a, + charisma: 0xbc, + }, + stats: Stats { + kills: 0x123456789abcdef, + deaths: 0x1234, + rests: 0x12345678, + hits: 0x123456789abcdef, + blocks: 0x12345678, + walked: 0x123456789abcdef, + runned: 0x123456789abcdef, + finished: true, + romances: 0x1234, + }, + weapon: Weapon::DualWield( + ( + Sword { + swordsmith: starknet::contract_address_const::<0x69>(), damage: 0x12345678, + }, + Sword { + swordsmith: starknet::contract_address_const::<0x69>(), damage: 0x12345678, + } + ) + ), + gold: 0x12345678, + }; + gas.end("chars init"); + + let gas = GasCounterTrait::start(); + let mut serialized = ArrayTrait::new(); + Serde::serialize(@char.heigth, ref serialized); + Serde::serialize(@char.abilities, ref serialized); + Serde::serialize(@char.stats, ref serialized); + Serde::serialize(@char.weapon, ref serialized); + Serde::serialize(@char.gold, ref serialized); + let serialized = ArrayTrait::span(@serialized); + gas.end("chars serialize"); + + let gas = GasCounterTrait::start(); + let values: Span = char.serialized_values(); + gas.end("chars values"); + + assert(serialized.len() == values.len(), 'serialized not equal'); + + let mut idx = 0; + loop { + if idx == serialized.len() { + break; + } + assert(serialized.at(idx) == values.at(idx), 'serialized differ'); + idx += 1; + }; + + let layout = match dojo::model::Model::::layout() { + Layout::Fixed(layout) => layout, + _ => panic!("expected fixed layout"), + }; + + let gas = GasCounterTrait::start(); + database::set('chars', '42', char.serialized_values(), 0, layout); + gas.end("chars db set"); + + let gas = GasCounterTrait::start(); + database::get('chars', '42', layout); + gas.end("chars db get"); +} + +#[test] +fn test_benchmark_set_entity() { + let world = deploy_world(); + let bob = starknet::contract_address_const::<0xb0b>(); + + let simple_entity_packed = Case { + owner: bob, sword: Sword { swordsmith: bob, damage: 42, }, material: 'iron' + }; + + let simple_entity_not_packed = CaseNotPacked { + owner: bob, sword: Sword { swordsmith: bob, damage: 42, }, material: 'iron' + }; + + let complex_entity = ComplexModel { + game_id: 0x123456789ABCDEF123456789ABCDEF, + player: bob, + first_name: "John", + last_name: "Doe", + weapons: array![ + Weapon::DualWield( + (Sword { swordsmith: bob, damage: 42 }, Sword { swordsmith: bob, damage: 800 }) + ), + Weapon::Fists( + (Sword { swordsmith: bob, damage: 300 }, Sword { swordsmith: bob, damage: 1200 }) + ) + ], + abilities: ( + Abilities { + strength: 12, + dexterity: 32, + constitution: 8, + intelligence: 14, + wisdom: 1, + charisma: 25, + }, + Abilities { + strength: 28, + dexterity: 13, + constitution: 18, + intelligence: 2, + wisdom: 1, + charisma: 43, + } + ), + stats: Stats { + kills: 99, + deaths: 1, + rests: 23, + hits: 8192, + blocks: 4301, + walked: 12, + runned: 32, + finished: true, + romances: 65 + }, + }; + + let gas = GasCounterTrait::start(); + world + .set_entity( + model_selector: Model::::selector(DOJO_NSH), + index: ModelIndex::Keys(simple_entity_packed.serialized_keys()), + values: simple_entity_packed.serialized_values(), + layout: Model::::layout() + ); + gas.end("World::SetEntity::SimplePacked"); + + let gas = GasCounterTrait::start(); + world + .set_entity( + model_selector: Model::::selector(), + index: ModelIndex::Keys(simple_entity_not_packed.serialized_keys()), + values: simple_entity_not_packed.serialized_values(), + layout: Model::::layout() + ); + gas.end("World::SetEntity::SimpleNotPacked"); + + let gas = GasCounterTrait::start(); + world + .set_entity( + model_selector: Model::::selector(), + index: ModelIndex::Keys(complex_entity.keys()), + values: complex_entity.serialized_values(), + layout: Model::::layout() + ); + gas.end("World::SetEntity::ComplexModel"); + + let gas = GasCounterTrait::start(); + world.set(@simple_entity_packed); + gas.end("Model::Set::SimplePacked"); + + let gas = GasCounterTrait::start(); + world.set(@simple_entity_not_packed); + gas.end("Model::Set::SimpleNotPacked"); + + let gas = GasCounterTrait::start(); + world.set(@complex_entity); + gas.end("Model::Set::ComplexModel"); + + let gas = GasCounterTrait::start(); + set!(world, (simple_entity_packed,)); + gas.end("Macro::Set::SimplePacked"); + + let gas = GasCounterTrait::start(); + set!(world, (simple_entity_not_packed,)); + gas.end("Macro::Set::SimpleNotPacked"); + + let gas = GasCounterTrait::start(); + set!(world, (complex_entity,)); + gas.end("Macro::Set::ComplexModel"); +} + diff --git a/crates/dojo/core-foundry-test/src/tests/contract.cairo b/crates/dojo/core-foundry-test/src/tests/contract.cairo new file mode 100644 index 0000000000..7f601fa12a --- /dev/null +++ b/crates/dojo/core-foundry-test/src/tests/contract.cairo @@ -0,0 +1,182 @@ +use core::option::OptionTrait; +use core::traits::TryInto; + +use starknet::ClassHash; + +use dojo::contract::components::upgradeable::{IUpgradeableDispatcher, IUpgradeableDispatcherTrait}; +use dojo::world::IWorldDispatcherTrait; + +use crate::tests::helpers::deploy_world; + +#[starknet::contract] +pub mod contract_invalid_upgrade { + #[storage] + struct Storage {} + + #[abi(per_item)] + #[generate_trait] + pub impl InvalidImpl of InvalidContractTrait { + #[external(v0)] + fn no_dojo_name(self: @ContractState) -> ByteArray { + "test_contract" + } + } +} + +#[dojo::contract] +mod test_contract {} + +#[starknet::interface] +pub trait IQuantumLeap { + fn plz_more_tps(self: @T) -> felt252; +} + +#[starknet::contract] +pub mod test_contract_upgrade { + use dojo::contract::IContract; + use dojo::meta::IDeployedResource; + use dojo::world::IWorldDispatcher; + use dojo::contract::components::world_provider::IWorldProvider; + + #[storage] + struct Storage {} + + #[constructor] + fn constructor(ref self: ContractState) {} + + #[abi(embed_v0)] + pub impl QuantumLeap of super::IQuantumLeap { + fn plz_more_tps(self: @ContractState) -> felt252 { + 'daddy' + } + } + + #[abi(embed_v0)] + pub impl WorldProviderImpl of IWorldProvider { + fn world_dispatcher(self: @ContractState) -> IWorldDispatcher { + IWorldDispatcher { contract_address: starknet::contract_address_const::<'world'>() } + } + } + + #[abi(embed_v0)] + pub impl ContractImpl of IContract {} + + #[abi(embed_v0)] + pub impl Contract_DeployedContractImpl of IDeployedResource { + fn dojo_name(self: @ContractState) -> ByteArray { + "test_contract" + } + } +} + +#[test] +#[available_gas(7000000)] +fn test_upgrade_from_world() { + let world = deploy_world(); + let world = world.dispatcher; + + let base_address = world + .register_contract('salt', "dojo", test_contract::TEST_CLASS_HASH.try_into().unwrap()); + let new_class_hash: ClassHash = test_contract_upgrade::TEST_CLASS_HASH.try_into().unwrap(); + + world.upgrade_contract("dojo", new_class_hash); + + let quantum_dispatcher = IQuantumLeapDispatcher { contract_address: base_address }; + assert(quantum_dispatcher.plz_more_tps() == 'daddy', 'quantum leap failed'); +} + +#[test] +#[available_gas(7000000)] +#[should_panic(expected: ('ENTRYPOINT_NOT_FOUND', 'ENTRYPOINT_FAILED'))] +fn test_upgrade_from_world_not_world_provider() { + let world = deploy_world(); + let world = world.dispatcher; + + let _ = world + .register_contract('salt', "dojo", test_contract::TEST_CLASS_HASH.try_into().unwrap()); + let new_class_hash: ClassHash = contract_invalid_upgrade::TEST_CLASS_HASH.try_into().unwrap(); + + world.upgrade_contract("dojo", new_class_hash); +} + +#[test] +#[available_gas(6000000)] +#[should_panic(expected: ('must be called by world', 'ENTRYPOINT_FAILED'))] +fn test_upgrade_direct() { + let world = deploy_world(); + let world = world.dispatcher; + + let base_address = world + .register_contract('salt', "dojo", test_contract::TEST_CLASS_HASH.try_into().unwrap()); + let new_class_hash: ClassHash = test_contract_upgrade::TEST_CLASS_HASH.try_into().unwrap(); + + let upgradeable_dispatcher = IUpgradeableDispatcher { contract_address: base_address }; + upgradeable_dispatcher.upgrade(new_class_hash); +} + +#[starknet::interface] +trait IMetadataOnly { + fn dojo_name(self: @T) -> ByteArray; +} + +#[starknet::contract] +mod invalid_legacy_model { + #[storage] + struct Storage {} + + #[abi(embed_v0)] + impl InvalidModelMetadata of super::IMetadataOnly { + fn dojo_name(self: @ContractState) -> ByteArray { + "invalid_legacy_model" + } + } +} + +#[starknet::contract] +mod invalid_legacy_model_world { + #[storage] + struct Storage {} + + #[abi(embed_v0)] + impl InvalidModelName of super::IMetadataOnly { + fn dojo_name(self: @ContractState) -> ByteArray { + "invalid_legacy_model" + } + } +} + +#[starknet::contract] +mod invalid_model { + #[storage] + struct Storage {} + + #[abi(embed_v0)] + impl InvalidModelSelector of super::IMetadataOnly { + fn dojo_name(self: @ContractState) -> ByteArray { + "invalid_model" + } + } +} + +#[starknet::contract] +mod invalid_model_world { + #[storage] + struct Storage {} + + #[abi(embed_v0)] + impl InvalidModelSelector of super::IMetadataOnly { + fn dojo_name(self: @ContractState) -> ByteArray { + "invalid_model_world" + } + } +} + +#[test] +#[available_gas(6000000)] +#[should_panic(expected: "Namespace `` is invalid according to Dojo naming rules: ^[a-zA-Z0-9_]+$")] +fn test_register_namespace_empty_name() { + let world = deploy_world(); + let world = world.dispatcher; + + world.register_namespace(""); +} diff --git a/crates/dojo/core-foundry-test/src/tests/event/event.cairo b/crates/dojo/core-foundry-test/src/tests/event/event.cairo new file mode 100644 index 0000000000..2753abdcc8 --- /dev/null +++ b/crates/dojo/core-foundry-test/src/tests/event/event.cairo @@ -0,0 +1,19 @@ +#[derive(Drop, Serde)] +#[dojo::event] +struct FooEvent { + #[key] + k1: u8, + #[key] + k2: felt252, + v1: u128, + v2: u32 +} + +#[test] +fn test_event_definition() { + let definition = dojo::event::Event::::definition(); + + assert_eq!(definition.name, dojo::event::Event::::name()); + assert_eq!(definition.layout, dojo::event::Event::::layout()); + assert_eq!(definition.schema, dojo::event::Event::::schema()); +} diff --git a/crates/dojo/core-foundry-test/src/tests/expanded/selector_attack.cairo b/crates/dojo/core-foundry-test/src/tests/expanded/selector_attack.cairo new file mode 100644 index 0000000000..a9ff2bda75 --- /dev/null +++ b/crates/dojo/core-foundry-test/src/tests/expanded/selector_attack.cairo @@ -0,0 +1,64 @@ +//! Test some manually expanded code for permissioned contract deployment and resource registration. +//! + +#[starknet::contract] +pub mod attacker_contract { + use dojo::world::IWorldDispatcher; + + #[storage] + struct Storage { + world_dispatcher: IWorldDispatcher, + } + + #[abi(embed_v0)] + pub impl DojoDeployedModelImpl of dojo::meta::IDeployedResource { + fn dojo_name(self: @ContractState) -> ByteArray { + "test_1" + } + } +} + +#[starknet::contract] +pub mod attacker_model { + #[storage] + struct Storage {} + + #[abi(embed_v0)] + impl DojoDeployedModelImpl of dojo::meta::IDeployedResource { + fn dojo_name(self: @ContractState) -> ByteArray { + "foo" + } + } + + #[abi(embed_v0)] + impl DojoStoredModelImpl of dojo::meta::interface::IStoredResource { + fn layout(self: @ContractState) -> dojo::meta::Layout { + dojo::meta::Layout::Fixed([].span()) + } + + fn schema(self: @ContractState) -> dojo::meta::introspect::Struct { + dojo::meta::introspect::Struct { name: 'm1', attrs: [].span(), children: [].span() } + } + } + + #[abi(embed_v0)] + impl DojoModelImpl of dojo::model::IModel { + fn unpacked_size(self: @ContractState) -> Option { + Option::None + } + + fn packed_size(self: @ContractState) -> Option { + Option::None + } + + fn definition(self: @ContractState) -> dojo::model::ModelDef { + dojo::model::ModelDef { + name: DojoDeployedModelImpl::dojo_name(self), + layout: DojoStoredModelImpl::layout(self), + schema: DojoStoredModelImpl::schema(self), + packed_size: Self::packed_size(self), + unpacked_size: Self::unpacked_size(self), + } + } + } +} diff --git a/crates/dojo/core-foundry-test/src/tests/helpers/event.cairo b/crates/dojo/core-foundry-test/src/tests/helpers/event.cairo new file mode 100644 index 0000000000..4b41eaf263 --- /dev/null +++ b/crates/dojo/core-foundry-test/src/tests/helpers/event.cairo @@ -0,0 +1,110 @@ +use core::starknet::ContractAddress; + +use dojo::world::{IWorldDispatcher}; + +use crate::world::{spawn_test_world, NamespaceDef, TestResource}; + +/// This file contains some partial event contracts written without the dojo::event +/// attribute, to avoid having several contracts with a same name/classhash, +/// as the test runner does not differenciate them. +/// These event contracts are used to test event upgrades in tests/event.cairo. + +// This event is used as a base to create the "previous" version of an event to be upgraded. +#[derive(Introspect)] +struct FooBaseEvent { + #[key] + pub caller: ContractAddress, + pub a: felt252, + pub b: u128, +} + +#[derive(Copy, Drop, Serde, Debug)] +#[dojo::event] +pub struct FooEventBadLayoutType { + #[key] + pub caller: ContractAddress, + pub a: felt252, + pub b: u128, +} + +#[derive(Copy, Drop, Serde, Debug)] +#[dojo::event] +struct FooEventMemberRemoved { + #[key] + pub caller: ContractAddress, + pub a: felt252, + pub b: u128, +} + +#[derive(Copy, Drop, Serde, Debug)] +#[dojo::event] +struct FooEventMemberAddedButRemoved { + #[key] + pub caller: ContractAddress, + pub a: felt252, + pub b: u128, +} + +#[derive(Copy, Drop, Serde, Debug)] +#[dojo::event] +struct FooEventMemberAddedButMoved { + #[key] + pub caller: ContractAddress, + pub a: felt252, + pub b: u128, +} + +#[derive(Copy, Drop, Serde, Debug)] +#[dojo::event] +struct FooEventMemberAdded { + #[key] + pub caller: ContractAddress, + pub a: felt252, + pub b: u128, +} + +pub fn deploy_world_for_event_upgrades() -> IWorldDispatcher { + let namespace_def = NamespaceDef { + namespace: "dojo", resources: [ + TestResource::Event(old_foo_event_bad_layout_type::TEST_CLASS_HASH.try_into().unwrap()), + TestResource::Event(e_FooEventMemberRemoved::TEST_CLASS_HASH.try_into().unwrap()), + TestResource::Event( + e_FooEventMemberAddedButRemoved::TEST_CLASS_HASH.try_into().unwrap() + ), + TestResource::Event(e_FooEventMemberAddedButMoved::TEST_CLASS_HASH.try_into().unwrap()), + TestResource::Event(e_FooEventMemberAdded::TEST_CLASS_HASH.try_into().unwrap()), + ].span() + }; + spawn_test_world([namespace_def].span()).dispatcher +} + +#[starknet::contract] +pub mod old_foo_event_bad_layout_type { + #[storage] + struct Storage {} + + #[abi(embed_v0)] + impl DeployedEventImpl of dojo::meta::interface::IDeployedResource { + fn dojo_name(self: @ContractState) -> ByteArray { + "FooEventBadLayoutType" + } + } + + #[abi(embed_v0)] + impl StoredImpl of dojo::meta::interface::IStoredResource { + fn schema(self: @ContractState) -> dojo::meta::introspect::Struct { + if let dojo::meta::introspect::Ty::Struct(mut s) = + dojo::meta::introspect::Introspect::::ty() { + s.name = 'FooEventBadLayoutType'; + s + } else { + panic!("Unexpected schema.") + } + } + + fn layout(self: @ContractState) -> dojo::meta::Layout { + // Should never happen as dojo::event always derive Introspect. + dojo::meta::Layout::Fixed([].span()) + } + } +} diff --git a/crates/dojo/core-foundry-test/src/tests/helpers/helpers.cairo b/crates/dojo/core-foundry-test/src/tests/helpers/helpers.cairo new file mode 100644 index 0000000000..f0e859ec86 --- /dev/null +++ b/crates/dojo/core-foundry-test/src/tests/helpers/helpers.cairo @@ -0,0 +1,255 @@ +use starknet::{ContractAddress}; + +use dojo::world::{IWorldDispatcher, WorldStorage, WorldStorageTrait}; +use dojo::model::Model; + +use crate::world::{ + spawn_test_world, NamespaceDef, TestResource, ContractDefTrait, WorldStorageTestTrait +}; + +pub const DOJO_NSH: felt252 = 0x309e09669bc1fdc1dd6563a7ef862aa6227c97d099d08cc7b81bad58a7443fa; + +#[derive(Copy, Drop, Serde, Debug)] +#[dojo::event] +pub struct SimpleEvent { + #[key] + pub id: u32, + pub data: (felt252, felt252), +} + +#[derive(Copy, Drop, Serde, Debug)] +#[dojo::model] +pub struct Foo { + #[key] + pub caller: ContractAddress, + pub a: felt252, + pub b: u128, +} + +#[derive(Drop, Serde, Debug)] +#[dojo::model] +pub struct NotCopiable { + #[key] + pub caller: ContractAddress, + pub a: Array, + pub b: ByteArray, +} + +#[starknet::contract] +pub mod foo_invalid_name { + use dojo::model::IModel; + + #[storage] + struct Storage {} + + #[abi(embed_v0)] + pub impl DeployedModelImpl of dojo::meta::IDeployedResource { + fn dojo_name(self: @ContractState) -> ByteArray { + "foo-bis" + } + } + + #[abi(embed_v0)] + pub impl StoredModelImpl of dojo::meta::interface::IStoredResource { + fn layout(self: @ContractState) -> dojo::meta::Layout { + dojo::meta::Layout::Fixed([].span()) + } + + fn schema(self: @ContractState) -> dojo::meta::introspect::Struct { + dojo::meta::introspect::Struct { name: 'foo', attrs: [].span(), children: [].span() } + } + } + + #[abi(embed_v0)] + pub impl ModelImpl of IModel { + fn unpacked_size(self: @ContractState) -> Option { + Option::None + } + + fn packed_size(self: @ContractState) -> Option { + Option::None + } + + fn definition(self: @ContractState) -> dojo::model::ModelDef { + dojo::model::ModelDef { + name: DeployedModelImpl::dojo_name(self), + layout: StoredModelImpl::layout(self), + schema: StoredModelImpl::schema(self), + packed_size: Self::packed_size(self), + unpacked_size: Self::unpacked_size(self), + } + } + } +} + +#[starknet::interface] +pub trait IFooSetter { + fn set_foo(ref self: T, a: felt252, b: u128); +} + +#[dojo::contract] +pub mod foo_setter { + use super::{Foo, IFooSetter}; + use dojo::model::ModelStorage; + + #[abi(embed_v0)] + impl IFooSetterImpl of IFooSetter { + fn set_foo(ref self: ContractState, a: felt252, b: u128) { + let mut world = self.world(@"dojo"); + world.write_model(@Foo { caller: starknet::get_caller_address(), a, b }); + } + } +} + +#[dojo::contract] +pub mod test_contract {} + +#[dojo::contract] +pub mod test_contract_with_dojo_init_args { + fn dojo_init(ref self: ContractState, arg1: felt252) { + let _a = arg1; + } +} + +#[derive(IntrospectPacked, Copy, Drop, Serde)] +pub struct Sword { + pub swordsmith: ContractAddress, + pub damage: u32, +} + +#[derive(IntrospectPacked, Copy, Drop, Serde)] +#[dojo::model] +pub struct Case { + #[key] + pub owner: ContractAddress, + pub sword: Sword, + pub material: felt252, +} + +#[derive(IntrospectPacked, Copy, Drop, Serde)] +#[dojo::model] +pub struct Character { + #[key] + pub caller: ContractAddress, + pub heigth: felt252, + pub abilities: Abilities, + pub stats: Stats, + pub weapon: Weapon, + pub gold: u32, +} + +#[derive(IntrospectPacked, Copy, Drop, Serde)] +pub struct Abilities { + pub strength: u8, + pub dexterity: u8, + pub constitution: u8, + pub intelligence: u8, + pub wisdom: u8, + pub charisma: u8, +} + +#[derive(IntrospectPacked, Copy, Drop, Serde)] +pub struct Stats { + pub kills: u128, + pub deaths: u16, + pub rests: u32, + pub hits: u64, + pub blocks: u32, + pub walked: felt252, + pub runned: felt252, + pub finished: bool, + pub romances: u16, +} + +#[derive(IntrospectPacked, Copy, Drop, Serde)] +pub enum Weapon { + DualWield: (Sword, Sword), + Fists: (Sword, Sword), // Introspect requires same arms +} + +#[starknet::interface] +pub trait Ibar { + fn set_foo(self: @TContractState, a: felt252, b: u128); + fn delete_foo(self: @TContractState); +} + +#[dojo::contract] +pub mod bar { + use core::traits::Into; + use starknet::{get_caller_address}; + use dojo::model::{ModelStorage, ModelPtr}; + + use super::{Foo, IWorldDispatcher}; + + #[storage] + struct Storage { + world: IWorldDispatcher, + } + + #[abi(embed_v0)] + impl IbarImpl of super::Ibar { + fn set_foo(self: @ContractState, a: felt252, b: u128) { + let mut world = self.world(@"dojo"); + world.write_model(@Foo { caller: get_caller_address(), a, b }); + } + + fn delete_foo(self: @ContractState) { + let mut world = self.world(@"dojo"); + let ptr = ModelPtr::< + Foo + > { id: core::poseidon::poseidon_hash_span([get_caller_address().into()].span()) }; + world.erase_model_ptr(ptr); + } + } +} + +/// Deploys an empty world with the `dojo` namespace. +pub fn deploy_world() -> WorldStorage { + let namespace_def = NamespaceDef { namespace: "dojo", resources: [].span(), }; + + spawn_test_world([namespace_def].span()) +} + +/// Deploys an empty world with the `dojo` namespace and registers the `foo` model. +/// No permissions are granted. +pub fn deploy_world_and_foo() -> (WorldStorage, felt252) { + let namespace_def = NamespaceDef { + namespace: "dojo", resources: [ + TestResource::Model(m_Foo::TEST_CLASS_HASH), + TestResource::Model(m_NotCopiable::TEST_CLASS_HASH), + ].span(), + }; + + (spawn_test_world([namespace_def].span()), Model::::selector(DOJO_NSH)) +} + +/// Deploys an empty world with the `dojo` namespace and registers the `foo` model. +/// Grants the `bar` contract writer permissions to the `foo` model. +pub fn deploy_world_and_bar() -> (WorldStorage, IbarDispatcher) { + let namespace_def = NamespaceDef { + namespace: "dojo", resources: [ + TestResource::Model(m_Foo::TEST_CLASS_HASH), + TestResource::Contract(bar::TEST_CLASS_HASH), + ].span(), + }; + + let bar_def = ContractDefTrait::new(@"dojo", @"bar") + .with_writer_of([Model::::selector(DOJO_NSH)].span()); + + let mut world = spawn_test_world([namespace_def].span()); + world.sync_perms_and_inits([bar_def].span()); + + let (bar_address, _) = world.dns(@"bar").unwrap(); + let bar_contract = IbarDispatcher { contract_address: bar_address }; + + (world, bar_contract) +} + +pub fn drop_all_events(address: ContractAddress) { + loop { + match starknet::testing::pop_log_raw(address) { + core::option::Option::Some(_) => {}, + core::option::Option::None => { break; }, + }; + } +} diff --git a/crates/dojo/core-foundry-test/src/tests/helpers/model.cairo b/crates/dojo/core-foundry-test/src/tests/helpers/model.cairo new file mode 100644 index 0000000000..238f3e4fe0 --- /dev/null +++ b/crates/dojo/core-foundry-test/src/tests/helpers/model.cairo @@ -0,0 +1,71 @@ +use core::starknet::ContractAddress; + +use dojo::world::IWorldDispatcher; + +use crate::world::{spawn_test_world, NamespaceDef, TestResource}; + +/// This file contains some partial model contracts written without the dojo::model +/// attribute, to avoid having several contracts with a same name/classhash, +/// as the test runner does not differenciate them. +/// These model contracts are used to test model upgrades in tests/model.cairo. + +#[derive(IntrospectPacked, Copy, Drop, Serde)] +#[dojo::model] +struct FooModelBadLayoutType { + #[key] + pub caller: ContractAddress, + pub a: felt252, + pub b: u128, +} + +#[derive(Introspect, Copy, Drop, Serde)] +#[dojo::model] +struct FooModelMemberRemoved { + #[key] + pub caller: ContractAddress, + pub a: felt252, + pub b: u128, +} + +#[derive(Introspect, Copy, Drop, Serde)] +#[dojo::model] +struct FooModelMemberAddedButRemoved { + #[key] + pub caller: ContractAddress, + pub a: felt252, + pub b: u128, +} + +#[derive(Introspect, Copy, Drop, Serde)] +#[dojo::model] +struct FooModelMemberAddedButMoved { + #[key] + pub caller: ContractAddress, + pub a: felt252, + pub b: u128, +} + +#[derive(Introspect, Copy, Drop, Serde)] +#[dojo::model] +struct FooModelMemberAdded { + #[key] + pub caller: ContractAddress, + pub a: felt252, + pub b: u128, +} + + +pub fn deploy_world_for_model_upgrades() -> IWorldDispatcher { + let namespace_def = NamespaceDef { + namespace: "dojo", resources: [ + TestResource::Model(m_FooModelBadLayoutType::TEST_CLASS_HASH.try_into().unwrap()), + TestResource::Model(m_FooModelMemberRemoved::TEST_CLASS_HASH.try_into().unwrap()), + TestResource::Model( + m_FooModelMemberAddedButRemoved::TEST_CLASS_HASH.try_into().unwrap() + ), + TestResource::Model(m_FooModelMemberAddedButMoved::TEST_CLASS_HASH.try_into().unwrap()), + TestResource::Model(m_FooModelMemberAdded::TEST_CLASS_HASH.try_into().unwrap()), + ].span() + }; + spawn_test_world([namespace_def].span()).dispatcher +} diff --git a/crates/dojo/core-foundry-test/src/tests/meta/introspect.cairo b/crates/dojo/core-foundry-test/src/tests/meta/introspect.cairo new file mode 100644 index 0000000000..6f994f5521 --- /dev/null +++ b/crates/dojo/core-foundry-test/src/tests/meta/introspect.cairo @@ -0,0 +1,310 @@ +use dojo::meta::introspect::Introspect; +use dojo::meta::{Layout, FieldLayout}; + +#[derive(Drop, Introspect)] +struct Base { + value: u32, +} + +#[derive(Drop, Introspect)] +struct WithArray { + value: u32, + arr: Array +} + +#[derive(Drop, Introspect)] +struct WithByteArray { + value: u32, + arr: ByteArray +} + +#[derive(Drop, Introspect)] +struct WithTuple { + value: u32, + arr: (u8, u16, u32) +} + +#[derive(Drop, Introspect)] +struct WithNestedTuple { + value: u32, + arr: (u8, (u16, u128, u256), u32) +} + +#[derive(Drop, Introspect)] +struct WithNestedArrayInTuple { + value: u32, + arr: (u8, (u16, Array, u256), u32) +} + +#[derive(Drop, IntrospectPacked)] +struct Vec3 { + x: u32, + y: u32, + z: u32 +} + +#[derive(IntrospectPacked)] +struct Translation { + from: Vec3, + to: Vec3 +} + +#[derive(Drop, IntrospectPacked)] +struct StructInnerNotPacked { + x: Base +} + +#[derive(Drop, Introspect)] +enum EnumNoData { + One, + Two, + Three +} + +#[derive(Drop, Introspect)] +enum EnumWithSameData { + One: u256, + Two: u256, + Three: u256 +} + +#[derive(Drop, Introspect)] +enum EnumWithSameTupleData { + One: (u256, u32), + Two: (u256, u32), + Three: (u256, u32) +} + +#[derive(Drop, Introspect)] +enum EnumWithVariousData { + One: u32, + Two: (u8, u16), + Three: Array, +} + + +#[derive(Drop, IntrospectPacked)] +enum EnumPacked { + A: u32, + B: u32, +} + +#[derive(Drop, IntrospectPacked)] +enum EnumInnerPacked { + A: (EnumPacked, Vec3), + B: (EnumPacked, Vec3), +} + +#[derive(Drop, IntrospectPacked)] +enum EnumInnerNotPacked { + A: (EnumPacked, Base), + B: (EnumPacked, Base), +} + +#[derive(Drop, Introspect)] +struct StructWithOption { + x: Option +} + +#[derive(Drop, Introspect)] +struct Generic { + value: T, +} + +fn field(selector: felt252, layout: Layout) -> FieldLayout { + FieldLayout { selector, layout } +} + +fn fixed(values: Array) -> Layout { + Layout::Fixed(values.span()) +} + +fn tuple(values: Array) -> Layout { + Layout::Tuple(values.span()) +} + +fn _enum(values: Array>) -> Layout { + let mut items = array![]; + let mut i = 0; + + loop { + if i >= values.len() { + break; + } + + let v = *values.at(i); + match v { + Option::Some(v) => { items.append(field(i.into(), v)); }, + Option::None => { items.append(field(i.into(), fixed(array![]))) } + } + + i += 1; + }; + + Layout::Enum(items.span()) +} + +fn arr(item_layout: Layout) -> Layout { + Layout::Array([item_layout].span()) +} + +#[test] +#[available_gas(2000000)] +fn test_generic_introspect() { + let _generic = Generic { value: Base { value: 123 } }; +} + +#[test] +fn test_size_basic_struct() { + let size = Introspect::::size(); + assert!(size.is_some()); + assert!(size.unwrap() == 1); +} + +#[test] +fn test_size_with_array() { + assert!(Introspect::::size().is_none()); +} + +#[test] +fn test_size_with_byte_array() { + assert!(Introspect::::size().is_none()); +} + +#[test] +fn test_size_with_tuple() { + let size = Introspect::::size(); + assert!(size.is_some()); + assert!(size.unwrap() == 4); +} + +#[test] +fn test_size_with_nested_tuple() { + let size = Introspect::::size(); + assert!(size.is_some()); + assert!(size.unwrap() == 7); +} + +#[test] +fn test_size_with_nested_array_in_tuple() { + let size = Introspect::::size(); + assert!(size.is_none()); +} + +#[test] +fn test_size_of_enum_without_variant_data() { + let size = Introspect::::size(); + assert!(size.is_some()); + assert!(size.unwrap() == 1); +} + +#[test] +fn test_size_of_enum_with_same_variant_data() { + let size = Introspect::::size(); + assert!(size.is_some()); + assert!(size.unwrap() == 3); +} + +#[test] +fn test_size_of_enum_with_same_tuple_variant_data() { + let size = Introspect::::size(); + assert!(size.is_some()); + assert!(size.unwrap() == 4); +} + + +#[test] +fn test_size_of_struct_with_option() { + let size = Introspect::::size(); + assert!(size.is_none()); +} + +#[test] +fn test_size_of_enum_with_variant_data() { + let size = Introspect::::size(); + assert!(size.is_none()); +} + +#[test] +fn test_layout_of_enum_without_variant_data() { + let layout = Introspect::::layout(); + let expected = _enum(array![ // One + Option::None, // Two + Option::None, // Three + Option::None,]); + + assert!(layout == expected); +} + +#[test] +fn test_layout_of_enum_with_variant_data() { + let layout = Introspect::::layout(); + let expected = _enum( + array![ + // One + Option::Some(fixed(array![32])), + // Two + Option::Some(tuple(array![fixed(array![8]), fixed(array![16])])), + // Three + Option::Some(arr(fixed(array![128]))), + ] + ); + + assert!(layout == expected); +} + +#[test] +fn test_layout_of_struct_with_option() { + let layout = Introspect::::layout(); + let expected = Layout::Struct( + array![field(selector!("x"), _enum(array![Option::Some(fixed(array![16])), Option::None]))] + .span() + ); + + assert!(layout == expected); +} + +#[test] +fn test_layout_of_packed_struct() { + let layout = Introspect::::layout(); + let expected = Layout::Fixed([32, 32, 32].span()); + + assert!(layout == expected); +} + +#[test] +fn test_layout_of_inner_packed_struct() { + let layout = Introspect::::layout(); + let expected = Layout::Fixed([32, 32, 32, 32, 32, 32].span()); + + assert!(layout == expected); +} + +#[test] +#[should_panic(expected: "A packed model layout must contain Fixed layouts only.")] +fn test_layout_of_not_packed_inner_struct() { + let _ = Introspect::::layout(); +} + + +#[test] +fn test_layout_of_packed_enum() { + let layout = Introspect::::layout(); + let expected = Layout::Fixed([8, 32].span()); + + assert!(layout == expected); +} + +#[test] +fn test_layout_of_inner_packed_enum() { + let layout = Introspect::::layout(); + let expected = Layout::Fixed([8, 8, 32, 32, 32, 32].span()); + + assert!(layout == expected); +} + +#[test] +#[should_panic(expected: "A packed model layout must contain Fixed layouts only.")] +fn test_layout_of_not_packed_inner_enum() { + let _ = Introspect::::layout(); +} diff --git a/crates/dojo/core-foundry-test/src/tests/model/model.cairo b/crates/dojo/core-foundry-test/src/tests/model/model.cairo new file mode 100644 index 0000000000..caecee30a1 --- /dev/null +++ b/crates/dojo/core-foundry-test/src/tests/model/model.cairo @@ -0,0 +1,183 @@ +use dojo::model::{Model, ModelValue, ModelStorage, ModelValueStorage}; +use dojo::world::WorldStorage; +use crate::world::{spawn_test_world, NamespaceDef, TestResource}; + +#[derive(Copy, Drop, Serde, Debug)] +#[dojo::model] +struct Foo { + #[key] + k1: u8, + #[key] + k2: felt252, + v1: u128, + v2: u32 +} + + +#[derive(Copy, Drop, Serde, Debug)] +#[dojo::model] +struct Foo2 { + #[key] + k1: u8, + #[key] + k2: felt252, + v1: u128, + v2: u32 +} + +fn namespace_def() -> NamespaceDef { + NamespaceDef { + namespace: "dojo_cairo_test", resources: [ + TestResource::Model(m_Foo::TEST_CLASS_HASH.try_into().unwrap()), + TestResource::Model(m_Foo2::TEST_CLASS_HASH.try_into().unwrap()), + ].span() + } +} + +fn spawn_foo_world() -> WorldStorage { + spawn_test_world([namespace_def()].span()) +} + +#[test] +fn test_model_definition() { + let definition = dojo::model::Model::::definition(); + + assert_eq!(definition.name, dojo::model::Model::::name()); + assert_eq!(definition.layout, dojo::model::Model::::layout()); + assert_eq!(definition.schema, dojo::model::Model::::schema()); + assert_eq!(definition.packed_size, dojo::model::Model::::packed_size()); + assert_eq!(definition.unpacked_size, dojo::meta::introspect::Introspect::::size()); +} + +#[test] +fn test_values() { + let mvalues = FooValue { v1: 3, v2: 4 }; + let expected_values = [3, 4].span(); + + let values = mvalues.serialized_values(); + assert!(expected_values == values); +} + +#[test] +fn test_from_values() { + let mut values = [3, 4].span(); + + let model_values: Option = ModelValue::::from_serialized(values); + assert!(model_values.is_some()); + let model_values = model_values.unwrap(); + assert!(model_values.v1 == 3 && model_values.v2 == 4); +} + +#[test] +fn test_from_values_bad_data() { + let mut values = [3].span(); + let res: Option = ModelValue::::from_serialized(values); + assert!(res.is_none()); +} + +#[test] +fn test_read_and_update_model_value() { + let mut world = spawn_foo_world(); + + let foo = Foo { k1: 1, k2: 2, v1: 3, v2: 4 }; + world.write_model(@foo); + + let entity_id = foo.entity_id(); + let mut model_value: FooValue = world.read_value(foo.keys()); + assert_eq!(model_value.v1, foo.v1); + assert_eq!(model_value.v2, foo.v2); + + model_value.v1 = 12; + model_value.v2 = 18; + + world.write_value_from_id(entity_id, @model_value); + + let read_values: FooValue = world.read_value(foo.keys()); + assert!(read_values.v1 == model_value.v1 && read_values.v2 == model_value.v2); +} + +#[test] +fn test_delete_model_value() { + let mut world = spawn_foo_world(); + + let foo = Foo { k1: 1, k2: 2, v1: 3, v2: 4 }; + world.write_model(@foo); + + let entity_id = foo.entity_id(); + ModelStorage::::erase_model(ref world, @foo); + + let read_values: FooValue = world.read_value_from_id(entity_id); + assert!(read_values.v1 == 0 && read_values.v2 == 0); +} + +#[test] +fn test_read_and_write_field_name() { + let mut world = spawn_foo_world(); + + let foo = Foo { k1: 1, k2: 2, v1: 3, v2: 4 }; + world.write_model(@foo); + + // Inference fails here, we need something better without too generics + // which also fails. + let v1 = world.read_member(foo.ptr(), selector!("v1")); + assert!(foo.v1 == v1); + + world.write_member(foo.ptr(), selector!("v1"), 42); + + let v1 = world.read_member(foo.ptr(), selector!("v1")); + assert!(v1 == 42); +} + +#[test] +fn test_read_and_write_from_model() { + let mut world = spawn_foo_world(); + + let foo = Foo { k1: 1, k2: 2, v1: 3, v2: 4 }; + world.write_model(@foo); + + let foo2: Foo = world.read_model((foo.k1, foo.k2)); + + assert!(foo.k1 == foo2.k1 && foo.k2 == foo2.k2 && foo.v1 == foo2.v1 && foo.v2 == foo2.v2); +} + +#[test] +fn test_delete_from_model() { + let mut world = spawn_foo_world(); + + let foo = Foo { k1: 1, k2: 2, v1: 3, v2: 4 }; + world.write_model(@foo); + world.erase_model(@foo); + + let foo2: Foo = world.read_model((foo.k1, foo.k2)); + assert!(foo2.k1 == foo.k1 && foo2.k2 == foo.k2 && foo2.v1 == 0 && foo2.v2 == 0); +} + +#[test] +fn test_model_ptr_from_keys() { + let mut world = spawn_foo_world(); + let foo = Foo { k1: 1, k2: 2, v1: 3, v2: 4 }; + let ptr = Model::::ptr_from_keys(foo.keys()); + world.write_model(@foo); + let v1 = world.read_member(ptr, selector!("v1")); + assert!(foo.v1 == v1); +} + +#[test] +fn test_model_ptr_from_serialized_keys() { + let mut world = spawn_foo_world(); + let foo = Foo { k1: 1, k2: 2, v1: 3, v2: 4 }; + let ptr = Model::::ptr_from_serialized_keys(foo.serialized_keys()); + world.write_model(@foo); + let v1 = world.read_member(ptr, selector!("v1")); + assert!(foo.v1 == v1); +} + +#[test] +fn test_model_ptr_from_entity_id() { + let mut world = spawn_foo_world(); + let foo = Foo { k1: 1, k2: 2, v1: 3, v2: 4 }; + let ptr = Model::::ptr_from_id(foo.entity_id()); + world.write_model(@foo); + let v1 = world.read_member(ptr, selector!("v1")); + assert!(foo.v1 == v1); +} diff --git a/crates/dojo/core-foundry-test/src/tests/storage/database.cairo b/crates/dojo/core-foundry-test/src/tests/storage/database.cairo new file mode 100644 index 0000000000..ead165696f --- /dev/null +++ b/crates/dojo/core-foundry-test/src/tests/storage/database.cairo @@ -0,0 +1,62 @@ +use core::array::{ArrayTrait, SpanTrait}; + +use dojo::storage::database::{get, set}; + +#[test] +#[available_gas(1000000)] +fn test_database_basic() { + let mut values = ArrayTrait::new(); + values.append('database_test'); + values.append('42'); + + set('table', 'key', values.span(), 0, [251, 251].span()); + let res = get('table', 'key', [251, 251].span()); + + assert(res.at(0) == values.at(0), 'Value at 0 not equal!'); + assert(res.at(1) == values.at(1), 'Value at 0 not equal!'); + assert(res.len() == values.len(), 'Lengths not equal'); +} + +#[test] +#[available_gas(1500000)] +fn test_database_different_tables() { + let mut values = ArrayTrait::new(); + values.append(0x1); + values.append(0x2); + + let mut other = ArrayTrait::new(); + other.append(0x3); + other.append(0x4); + + set('first', 'key', values.span(), 0, [251, 251].span()); + set('second', 'key', other.span(), 0, [251, 251].span()); + let res = get('first', 'key', [251, 251].span()); + let other_res = get('second', 'key', [251, 251].span()); + + assert(res.len() == values.len(), 'Lengths not equal'); + assert(res.at(0) == values.at(0), 'Values different at `first`!'); + assert(other_res.at(0) == other_res.at(0), 'Values different at `second`!'); + assert(other_res.at(0) != res.at(0), 'Values the same for different!'); +} + +#[test] +#[available_gas(1500000)] +fn test_database_different_keys() { + let mut values = ArrayTrait::new(); + values.append(0x1); + values.append(0x2); + + let mut other = ArrayTrait::new(); + other.append(0x3); + other.append(0x4); + + set('table', 'key', values.span(), 0, [251, 251].span()); + set('table', 'other', other.span(), 0, [251, 251].span()); + let res = get('table', 'key', [251, 251].span()); + let other_res = get('table', 'other', [251, 251].span()); + + assert(res.len() == values.len(), 'Lengths not equal'); + assert(res.at(0) == values.at(0), 'Values different at `key`!'); + assert(other_res.at(0) == other_res.at(0), 'Values different at `other`!'); + assert(other_res.at(0) != res.at(0), 'Values the same for different!'); +} diff --git a/crates/dojo/core-foundry-test/src/tests/storage/packing.cairo b/crates/dojo/core-foundry-test/src/tests/storage/packing.cairo new file mode 100644 index 0000000000..1d2d32c315 --- /dev/null +++ b/crates/dojo/core-foundry-test/src/tests/storage/packing.cairo @@ -0,0 +1,413 @@ +use core::array::ArrayTrait; +use core::option::OptionTrait; +use core::traits::{Into, TryInto}; + +use dojo::storage::packing::{ + shl, shr, fpow, pack, unpack, pack_inner, unpack_inner, calculate_packed_size, pow2_const +}; + +#[test] +#[available_gas(9000000)] +fn test_bit_fpow() { + assert( + fpow( + 2, 250 + ) == 1809251394333065553493296640760748560207343510400633813116524750123642650624_u256, + '' + ) +} + + +#[test] +fn test_bit_pow2_const() { + assert( + pow2_const( + 250 + ) == 1809251394333065553493296640760748560207343510400633813116524750123642650624_u256, + '' + ) +} + +#[test] +#[available_gas(9000000)] +fn test_bit_shift() { + assert(1 == shl(1, 0), 'left == right'); + assert(1 == shr(1, 0), 'left == right'); + + assert(16 == shl(1, 4), 'left == right'); + assert(1 == shr(16, 4), 'left == right'); + + assert(shr(shl(1, 251), 251) == 1, 'left == right') +} + +#[test] +#[available_gas(9000000)] +fn test_pack_unpack_single() { + let mut packed = ArrayTrait::new(); + let mut packing: felt252 = 0; + let mut offset = 0; + pack_inner(@18, 251, ref packing, ref offset, ref packed); + packed.append(packing); + + assert(*packed.at(0) == 18, 'Packing single value'); + + let mut unpacking: felt252 = packed.pop_front().unwrap(); + let mut un_offset = 0; + let mut packed_span = packed.span(); + + let result = unpack_inner(251, ref packed_span, ref unpacking, ref un_offset).unwrap(); + assert(result == 18, 'Unpacked equals packed'); +} + +#[test] +#[available_gas(9000000)] +fn test_pack_unpack_felt252_u128() { + let mut packed = ArrayTrait::new(); + let mut packing: felt252 = 0; + let mut offset = 0; + pack_inner(@1337, 128, ref packing, ref offset, ref packed); + pack_inner(@420, 251, ref packing, ref offset, ref packed); + packed.append(packing); + + let mut unpacking: felt252 = packed.pop_front().unwrap(); + let mut un_offset = 0; + let mut packed_span = packed.span(); + + assert( + unpack_inner(128, ref packed_span, ref unpacking, ref un_offset).unwrap() == 1337, + 'Types u8' + ); + assert( + unpack_inner(252, ref packed_span, ref unpacking, ref un_offset).unwrap() == 420, 'Types u8' + ); +} + +#[test] +#[available_gas(100000000)] +fn test_pack_multiple() { + let mut packed = ArrayTrait::new(); + let mut packing: felt252 = 0; + let mut offset = 0; + + let mut i: u32 = 0; + loop { + if i >= 20 { + break; + } + pack_inner(@i.into(), 32, ref packing, ref offset, ref packed); + i += 1; + }; + packed.append(packing); + + assert( + *packed.at(0) == 0x6000000050000000400000003000000020000000100000000, 'Packed multiple 0' + ); + assert( + *packed.at(1) == 0xd0000000c0000000b0000000a000000090000000800000007, 'Packed multiple 1' + ); + assert(*packed.at(2) == 0x130000001200000011000000100000000f0000000e, 'Packed multiple 2'); +} + +#[test] +#[available_gas(500000000)] +fn test_pack_unpack_multiple() { + let mut packed = ArrayTrait::new(); + let mut packing: felt252 = 0; + let mut offset = 0; + + let mut i: u8 = 0; + loop { + if i >= 40 { + break; + } + let mut j: u32 = i.into(); + j = (j + 3) * j; + + pack_inner(@i.into(), 8, ref packing, ref offset, ref packed); + pack_inner(@j.into(), 32, ref packing, ref offset, ref packed); + + i += 1; + }; + packed.append(packing); + + let mut unpacking: felt252 = packed.pop_front().unwrap(); + let mut un_offset = 0; + let mut packed_span = packed.span(); + + i = 0; + loop { + if i >= 40 { + break; + } + let result_i = unpack_inner(8, ref packed_span, ref unpacking, ref un_offset).unwrap(); + let result_j = unpack_inner(32, ref packed_span, ref unpacking, ref un_offset).unwrap(); + + let mut j: u32 = i.into(); + j = (j + 3) * j; + + assert(result_i.try_into().unwrap() == i, 'Unpacked equals packed'); + assert(result_j.try_into().unwrap() == j, 'Unpacked equals packed'); + i += 1; + }; +} + +#[test] +#[available_gas(500000000)] +fn test_pack_unpack_types() { + let mut packed = ArrayTrait::new(); + let mut packing: felt252 = 0; + let mut offset = 0; + + pack_inner(@3, 8, ref packing, ref offset, ref packed); + pack_inner(@14, 16, ref packing, ref offset, ref packed); + pack_inner(@59, 32, ref packing, ref offset, ref packed); + pack_inner(@26, 64, ref packing, ref offset, ref packed); + pack_inner(@53, 128, ref packing, ref offset, ref packed); + pack_inner(@58, 251, ref packing, ref offset, ref packed); + pack_inner(@false.into(), 1, ref packing, ref offset, ref packed); + + let contract_address = starknet::contract_address_const::<3>(); + pack_inner(@contract_address.into(), 251, ref packing, ref offset, ref packed); + let class_hash = starknet::class_hash::class_hash_const::<1337>(); + pack_inner(@class_hash.into(), 251, ref packing, ref offset, ref packed); + + packed.append(packing); + + let mut unpacking: felt252 = packed.pop_front().unwrap(); + let mut un_offset = 0; + let mut packed_span = packed.span(); + + assert( + unpack_inner(8, ref packed_span, ref unpacking, ref un_offset) + .unwrap() + .try_into() + .unwrap() == 3_u8, + 'Types u8' + ); + assert( + unpack_inner(16, ref packed_span, ref unpacking, ref un_offset) + .unwrap() + .try_into() + .unwrap() == 14_u16, + 'Types u16' + ); + assert( + unpack_inner(32, ref packed_span, ref unpacking, ref un_offset) + .unwrap() + .try_into() + .unwrap() == 59_u32, + 'Types u32' + ); + assert( + unpack_inner(64, ref packed_span, ref unpacking, ref un_offset) + .unwrap() + .try_into() + .unwrap() == 26_u64, + 'Types u64' + ); + assert( + unpack_inner(128, ref packed_span, ref unpacking, ref un_offset) + .unwrap() + .try_into() + .unwrap() == 53_u128, + 'Types u128' + ); + assert( + unpack_inner(251, ref packed_span, ref unpacking, ref un_offset).unwrap() == 58_felt252, + 'Types felt252' + ); + assert( + unpack_inner(1, ref packed_span, ref unpacking, ref un_offset).unwrap() == false.into(), + 'Types bool' + ); + assert( + unpack_inner(251, ref packed_span, ref unpacking, ref un_offset) + .unwrap() + .try_into() + .unwrap() == contract_address, + 'Types ContractAddress' + ); + assert( + unpack_inner(251, ref packed_span, ref unpacking, ref un_offset) + .unwrap() + .try_into() + .unwrap() == class_hash, + 'Types ClassHash' + ); +} + +#[test] +#[available_gas(9000000)] +fn test_inner_pack_unpack_u256_single() { + let input: u256 = 2000; + let mut packed = ArrayTrait::new(); + let mut packing: felt252 = 0; + let mut offset = 0; + pack_inner(@input.low.into(), 128, ref packing, ref offset, ref packed); + pack_inner(@input.high.into(), 128, ref packing, ref offset, ref packed); + packed.append(packing); + + assert(*packed.at(0) == 2000, 'Packing low value'); + assert(*packed.at(1) == 0, 'Packing high value'); + + let mut unpacking: felt252 = packed.pop_front().unwrap(); + let mut un_offset = 0; + let mut packed_span = packed.span(); + + let low = unpack_inner(128, ref packed_span, ref unpacking, ref un_offset).unwrap(); + let high = unpack_inner(128, ref packed_span, ref unpacking, ref un_offset).unwrap(); + assert( + u256 { low: low.try_into().unwrap(), high: high.try_into().unwrap() } == input, + 'Unpacked equals packed' + ); +} + +#[test] +#[available_gas(9000000)] +fn test_pack_unpack_u256_single() { + let input: u256 = 2000; + let mut unpacked = ArrayTrait::new(); + input.serialize(ref unpacked); + let mut layout = ArrayTrait::new(); + layout.append(128); + layout.append(128); + let mut layout_span = layout.span(); + + let mut unpacked_span = unpacked.span(); + + let mut packed = ArrayTrait::new(); + pack(ref packed, ref unpacked_span, 0, ref layout_span); + + let mut layout = ArrayTrait::new(); + layout.append(128); + layout.append(128); + let mut layout_span = layout.span(); + + let mut unpacked = ArrayTrait::new(); + let mut packed_span = packed.span(); + unpack(ref unpacked, ref packed_span, ref layout_span); + let mut unpacked_span = unpacked.span(); + let output = core::serde::Serde::::deserialize(ref unpacked_span).unwrap(); + assert(input == output, 'invalid output'); +} + +#[test] +#[available_gas(9000000)] +fn test_pack_unpack_max_felt252() { + let MAX: felt252 = 3618502788666131213697322783095070105623107215331596699973092056135872020480; + let mut packed = ArrayTrait::new(); + let mut packing: felt252 = 0; + let mut offset = 0; + pack_inner(@MAX, 251, ref packing, ref offset, ref packed); + packed.append(packing); + + let mut unpacking: felt252 = 0; + let mut offset = 251; + let mut packed_span = packed.span(); + + let got = unpack_inner(251, ref packed_span, ref unpacking, ref offset).unwrap(); + assert(got == MAX, 'Types MAX'); +} + +#[test] +#[available_gas(9000000)] +fn test_pack_unpack_felt252_single() { + let input = 2000; + let mut unpacked = ArrayTrait::new(); + input.serialize(ref unpacked); + let mut layout = ArrayTrait::new(); + layout.append(251); + let mut layout_span = layout.span(); + + let mut unpacked_span = unpacked.span(); + + let mut packed = ArrayTrait::new(); + pack(ref packed, ref unpacked_span, 0, ref layout_span); + + let mut layout = ArrayTrait::new(); + layout.append(251); + let mut layout_span = layout.span(); + + let mut unpacked = ArrayTrait::new(); + let mut packed_span = packed.span(); + unpack(ref unpacked, ref packed_span, ref layout_span); + let mut unpacked_span = unpacked.span(); + let output = core::serde::Serde::::deserialize(ref unpacked_span).unwrap(); + assert(input == output, 'invalid output'); +} + +#[test] +fn test_pack_with_offset() { + let mut packed = array![]; + let mut unpacked = [1, 2, 3, 4, 5, 6, 7, 8, 9].span(); + let mut layout = [16, 128, 128, 8].span(); + + pack(ref packed, ref unpacked, 5, ref layout); + + assert!(packed.len() == 2, "bad packed length"); + + assert!(*packed.at(0) == 0x70006, "bad packed first item"); + assert!(*packed.at(1) == 0x0900000000000000000000000000000008, "bad packed second item"); +} + +#[test] +#[available_gas(9000000)] +fn test_calculate_packed_size() { + let mut layout = [128, 32].span(); + let got = calculate_packed_size(ref layout); + assert(got == 1, 'invalid length for [128, 32]'); + + let mut layout = [128, 128].span(); + let got = calculate_packed_size(ref layout); + assert(got == 2, 'invalid length for [128, 128]'); + + let mut layout = [251, 251].span(); + let got = calculate_packed_size(ref layout); + assert(got == 2, 'invalid length for [251, 251]'); + + let mut layout = [251].span(); + let got = calculate_packed_size(ref layout); + assert(got == 1, 'invalid length for [251]'); + + let mut layout = [32, 64, 128, 27].span(); + let got = calculate_packed_size(ref layout); + assert(got == 1, 'invalid length'); + + let mut layout = [32, 64, 128, 28].span(); + let got = calculate_packed_size(ref layout); + assert(got == 2, 'invalid length'); +} + +#[test] +#[available_gas(9000000)] +#[should_panic(expected: ('Invalid layout size',))] +fn test_pack_max_bits_value() { + let mut unpacked = array![1]; + let mut layout = array![253]; + + let mut layout_span = layout.span(); + let mut unpacked_span = unpacked.span(); + + let mut packed = array![]; + pack(ref packed, ref unpacked_span, 0, ref layout_span); +} + +#[test] +#[should_panic(expected: ('mismatched input lens',))] +fn test_pack_with_offset_exceeds_length() { + let mut packed = array![]; + let mut unpacked = [1, 2, 3, 4, 5, 6, 7, 8, 9].span(); + let mut layout = [16, 128, 128, 8].span(); + + pack(ref packed, ref unpacked, 6, ref layout); +} + +#[test] +#[should_panic(expected: ('mismatched input lens',))] +fn test_pack_with_offset_layout_too_long() { + let mut packed = array![]; + let mut unpacked = [1, 2, 3, 4, 5, 6, 7, 8, 9].span(); + let mut layout = [16, 128, 128, 8, 251].span(); + + pack(ref packed, ref unpacked, 5, ref layout); +} diff --git a/crates/dojo/core-foundry-test/src/tests/storage/storage.cairo b/crates/dojo/core-foundry-test/src/tests/storage/storage.cairo new file mode 100644 index 0000000000..5b6c1bfdac --- /dev/null +++ b/crates/dojo/core-foundry-test/src/tests/storage/storage.cairo @@ -0,0 +1,98 @@ +use core::array::ArrayTrait; +use core::array::SpanTrait; + +use dojo::storage::storage; + +#[test] +#[available_gas(2000000)] +fn test_storage() { + let mut keys = ArrayTrait::new(); + keys.append(0x1337); + + let mut values = ArrayTrait::new(); + values.append(0x1); + values.append(0x2); + + let layout = [251, 251].span(); + + storage::set(0, keys.span(), *values.at(0)); + assert(storage::get(0, keys.span()) == *values.at(0), 'value not set'); + + storage::set_many(0, keys.span(), values.span(), 0, layout).unwrap(); + let res = storage::get_many(0, keys.span(), layout).unwrap(); + assert(*res.at(0) == *values.at(0), 'value not set'); + assert(*res.at(1) == *values.at(1), 'value not set'); +} + +#[test] +#[available_gas(20000000)] +fn test_storage_empty() { + let mut keys = ArrayTrait::new(); + assert(storage::get(0, keys.span()) == 0x0, 'Value should be 0'); + let many = storage::get_many(0, keys.span(), [251, 251, 251].span()).unwrap(); + assert(many.len() == 0x3, 'Array should be len 3'); + assert((*many[0]) == 0x0, 'Array[0] should be 0'); + assert((*many[1]) == 0x0, 'Array[1] should be 0'); + assert((*many[2]) == 0x0, 'Array[2] should be 0'); + + let many = storage::get_many(0, keys.span(), [8, 8, 32].span()).unwrap(); + assert(many.len() == 0x3, 'Array should be len 3'); + assert((*many[0]) == 0x0, 'Array[0] should be 0'); + assert((*many[1]) == 0x0, 'Array[1] should be 0'); + assert((*many[2]) == 0x0, 'Array[2] should be 0'); +} + +#[test] +#[available_gas(2000000)] +fn test_storage_set_many() { + let mut keys = ArrayTrait::new(); + keys.append(0x966); + + let mut values = ArrayTrait::new(); + values.append(0x1); + values.append(0x2); + values.append(0x3); + values.append(0x4); + + storage::set_many(0, keys.span(), values.span(), 0, [251, 251, 251, 251].span()).unwrap(); + let many = storage::get_many(0, keys.span(), [251, 251, 251, 251].span()).unwrap(); + assert(many.at(0) == values.at(0), 'Value at 0 not equal!'); + assert(many.at(1) == values.at(1), 'Value at 1 not equal!'); + assert(many.at(2) == values.at(2), 'Value at 2 not equal!'); + assert(many.at(3) == values.at(3), 'Value at 3 not equal!'); +} + +#[test] +#[available_gas(2000000000)] +fn test_storage_set_many_several_segments() { + let mut keys = ArrayTrait::new(); + keys.append(0x966); + + let mut layout = ArrayTrait::new(); + let mut values = ArrayTrait::new(); + let mut i = 0; + loop { + if i == 1000 { + break; + } + + values.append(i); + layout.append(251_u8); + + i += 1; + }; + + storage::set_many(0, keys.span(), values.span(), 0, layout.span()).unwrap(); + let many = storage::get_many(0, keys.span(), layout.span()).unwrap(); + + let mut i = 0; + loop { + if i == 1000 { + break; + } + + assert(many.at(i) == values.at(i), 'Value not equal!'); + + i += 1; + }; +} diff --git a/crates/dojo/core-foundry-test/src/tests/utils/hash.cairo b/crates/dojo/core-foundry-test/src/tests/utils/hash.cairo new file mode 100644 index 0000000000..362cd65035 --- /dev/null +++ b/crates/dojo/core-foundry-test/src/tests/utils/hash.cairo @@ -0,0 +1,20 @@ +use dojo::model::Model; +use dojo::utils::selector_from_names; + +use crate::tests::helpers::DOJO_NSH; + +#[derive(Drop, Copy, Serde)] +#[dojo::model] +struct MyModel { + #[key] + x: u8, + y: u8 +} + +#[test] +fn test_selector_computation() { + let namespace = "dojo"; + let name = Model::::name(); + let selector = selector_from_names(@namespace, @name); + assert(selector == Model::::selector(DOJO_NSH), 'invalid computed selector'); +} diff --git a/crates/dojo/core-foundry-test/src/tests/utils/key.cairo b/crates/dojo/core-foundry-test/src/tests/utils/key.cairo new file mode 100644 index 0000000000..facd0a84c9 --- /dev/null +++ b/crates/dojo/core-foundry-test/src/tests/utils/key.cairo @@ -0,0 +1,17 @@ +use dojo::utils::{entity_id_from_serialized_keys, combine_key}; + +#[test] +fn test_entity_id_from_keys() { + let keys = [1, 2, 3].span(); + assert( + entity_id_from_serialized_keys(keys) == core::poseidon::poseidon_hash_span(keys), + 'bad entity ID' + ); +} + +#[test] +fn test_combine_key() { + assert( + combine_key(1, 2) == core::poseidon::poseidon_hash_span([1, 2].span()), 'combine key error' + ); +} diff --git a/crates/dojo/core-foundry-test/src/tests/utils/layout.cairo b/crates/dojo/core-foundry-test/src/tests/utils/layout.cairo new file mode 100644 index 0000000000..93ee8c4a4e --- /dev/null +++ b/crates/dojo/core-foundry-test/src/tests/utils/layout.cairo @@ -0,0 +1,64 @@ +use dojo::meta::{FieldLayout, Layout}; +use dojo::utils::{find_field_layout, find_model_field_layout}; + +#[test] +fn test_find_layout_when_exists() { + let layouts = [ + FieldLayout { selector: 'one', layout: Layout::Fixed([1].span()) }, + FieldLayout { selector: 'two', layout: Layout::Fixed([2].span()) }, + FieldLayout { selector: 'three', layout: Layout::Fixed([3].span()) }, + ].span(); + + let res = find_field_layout('two', layouts); + assert(res.is_some(), 'layout not found'); + let res = res.unwrap(); + assert(res == Layout::Fixed([2].span()), 'bad layout'); +} + +#[test] +fn test_find_layout_fails_when_not_exists() { + let layouts = [ + FieldLayout { selector: 'one', layout: Layout::Fixed([1].span()) }, + FieldLayout { selector: 'two', layout: Layout::Fixed([2].span()) }, + FieldLayout { selector: 'three', layout: Layout::Fixed([3].span()) }, + ].span(); + + let res = find_field_layout('four', layouts); + assert(res.is_none(), 'layout found'); +} + +#[test] +fn test_find_model_layout_when_exists() { + let model_layout = Layout::Struct( + [ + FieldLayout { selector: 'one', layout: Layout::Fixed([1].span()) }, + FieldLayout { selector: 'two', layout: Layout::Fixed([2].span()) }, + FieldLayout { selector: 'three', layout: Layout::Fixed([3].span()) }, + ].span() + ); + + let res = find_model_field_layout(model_layout, 'two'); + assert(res.is_some(), 'layout not found'); + let res = res.unwrap(); + assert(res == Layout::Fixed([2].span()), 'bad layout'); +} + +#[test] +fn test_find_model_layout_fails_when_not_exists() { + let model_layout = Layout::Struct( + [ + FieldLayout { selector: 'one', layout: Layout::Fixed([1].span()) }, + FieldLayout { selector: 'two', layout: Layout::Fixed([2].span()) }, + FieldLayout { selector: 'three', layout: Layout::Fixed([3].span()) }, + ].span() + ); + + let res = find_model_field_layout(model_layout, 'four'); + assert(res.is_none(), 'layout found'); +} + +#[test] +#[should_panic(expected: ('Unexpected model layout',))] +fn test_find_model_layout_fails_when_bad_model_layout() { + let _ = find_model_field_layout(Layout::Fixed([].span()), 'one'); +} diff --git a/crates/dojo/core-foundry-test/src/tests/utils/misc.cairo b/crates/dojo/core-foundry-test/src/tests/utils/misc.cairo new file mode 100644 index 0000000000..60973d4d58 --- /dev/null +++ b/crates/dojo/core-foundry-test/src/tests/utils/misc.cairo @@ -0,0 +1,30 @@ +use dojo::utils::{any_none, sum}; + +#[test] +fn test_any_none_when_one_none() { + assert( + any_none(@array![Option::Some(1_u8), Option::Some(2_u8), Option::None, Option::Some(3_u8)]), + 'None not found' + ) +} + +#[test] +fn test_any_none_when_no_none() { + assert( + any_none(@array![Option::Some(1_u8), Option::Some(2_u8), Option::Some(3_u8)]) == false, + 'None found' + ) +} + +#[test] +fn test_sum_when_empty_array() { + assert(sum::(array![]) == 0, 'bad sum'); +} + +#[test] +fn test_sum_when_some_none_and_values() { + assert( + sum::(array![Option::Some(1), Option::None, Option::Some(2), Option::Some(3)]) == 6, + 'bad sum' + ); +} diff --git a/crates/dojo/core-foundry-test/src/tests/utils/naming.cairo b/crates/dojo/core-foundry-test/src/tests/utils/naming.cairo new file mode 100644 index 0000000000..aa3fbab74f --- /dev/null +++ b/crates/dojo/core-foundry-test/src/tests/utils/naming.cairo @@ -0,0 +1,16 @@ +use dojo::utils::is_name_valid; + +#[test] +fn test_with_valid_names() { + assert!(is_name_valid(@"name")); + assert!(is_name_valid(@"NAME")); + assert!(is_name_valid(@"Name123")); + assert!(is_name_valid(@"Name123_")); +} + +#[test] +fn test_with_invalid_names() { + assert!(!is_name_valid(@"n@me")); + assert!(!is_name_valid(@"Name ")); + assert!(!is_name_valid(@"-name")); +} diff --git a/crates/dojo/core-foundry-test/src/tests/world/acl.cairo b/crates/dojo/core-foundry-test/src/tests/world/acl.cairo new file mode 100644 index 0000000000..26c19584f3 --- /dev/null +++ b/crates/dojo/core-foundry-test/src/tests/world/acl.cairo @@ -0,0 +1,299 @@ +use dojo::utils::bytearray_hash; +use dojo::world::IWorldDispatcherTrait; + +use crate::tests::helpers::{ + deploy_world, foo_setter, IFooSetterDispatcher, IFooSetterDispatcherTrait, deploy_world_and_foo +}; +use crate::tests::expanded::selector_attack::{attacker_model, attacker_contract}; + +#[test] +fn test_owner() { + let (world, foo_selector) = deploy_world_and_foo(); + + let world = world.dispatcher; + + let alice = starknet::contract_address_const::<0xa11ce>(); + let bob = starknet::contract_address_const::<0xb0b>(); + + assert(!world.is_owner(0, alice), 'should not be owner'); + assert(!world.is_owner(foo_selector, bob), 'should not be owner'); + + world.grant_owner(0, alice); + assert(world.is_owner(0, alice), 'should be owner'); + + world.grant_owner(foo_selector, bob); + assert(world.is_owner(foo_selector, bob), 'should be owner'); + + world.revoke_owner(0, alice); + assert(!world.is_owner(0, alice), 'should not be owner'); + + world.revoke_owner(foo_selector, bob); + assert(!world.is_owner(foo_selector, bob), 'should not be owner'); +} + + +#[test] +#[should_panic(expected: "Resource `42` is not registered")] +fn test_grant_owner_not_registered_resource() { + let world = deploy_world(); + let world = world.dispatcher; + + // 42 is not a registered resource ID + world.grant_owner(42, 69.try_into().unwrap()); +} + +#[test] +#[should_panic(expected: 'CONTRACT_NOT_DEPLOYED')] +fn test_grant_owner_through_malicious_contract() { + let (world, foo_selector) = deploy_world_and_foo(); + let world = world.dispatcher; + + let alice = starknet::contract_address_const::<0xa11ce>(); + let bob = starknet::contract_address_const::<0xb0b>(); + let malicious_contract = starknet::contract_address_const::<0xdead>(); + + world.grant_owner(foo_selector, alice); + + starknet::testing::set_account_contract_address(alice); + starknet::testing::set_contract_address(malicious_contract); + + world.grant_owner(foo_selector, bob); +} + +#[test] +#[should_panic( + expected: "Account `659918` does NOT have OWNER role on model (or its namespace) `Foo`" +)] +fn test_grant_owner_fails_for_non_owner() { + let (world, foo_selector) = deploy_world_and_foo(); + let world = world.dispatcher; + + let alice = starknet::contract_address_const::<0xa11ce>(); + let bob = starknet::contract_address_const::<0xb0b>(); + + starknet::testing::set_account_contract_address(alice); + starknet::testing::set_contract_address(alice); + + world.grant_owner(foo_selector, bob); +} + +#[test] +#[should_panic(expected: 'CONTRACT_NOT_DEPLOYED')] +fn test_revoke_owner_through_malicious_contract() { + let (world, foo_selector) = deploy_world_and_foo(); + let world = world.dispatcher; + + let alice = starknet::contract_address_const::<0xa11ce>(); + let bob = starknet::contract_address_const::<0xb0b>(); + let malicious_contract = starknet::contract_address_const::<0xdead>(); + + world.grant_owner(foo_selector, alice); + world.grant_owner(foo_selector, bob); + + starknet::testing::set_account_contract_address(alice); + starknet::testing::set_contract_address(malicious_contract); + + world.revoke_owner(foo_selector, bob); +} + +#[test] +#[should_panic( + expected: "Account `659918` does NOT have OWNER role on model (or its namespace) `Foo`" +)] +fn test_revoke_owner_fails_for_non_owner() { + let (world, foo_selector) = deploy_world_and_foo(); + let world = world.dispatcher; + + let alice = starknet::contract_address_const::<0xa11ce>(); + let bob = starknet::contract_address_const::<0xb0b>(); + + world.grant_owner(foo_selector, bob); + + starknet::testing::set_account_contract_address(alice); + starknet::testing::set_contract_address(alice); + + world.revoke_owner(foo_selector, bob); +} + +#[test] +#[available_gas(6000000)] +fn test_writer() { + let (world, foo_selector) = deploy_world_and_foo(); + let world = world.dispatcher; + + assert(!world.is_writer(foo_selector, 69.try_into().unwrap()), 'should not be writer'); + + world.grant_writer(foo_selector, 69.try_into().unwrap()); + assert(world.is_writer(foo_selector, 69.try_into().unwrap()), 'should be writer'); + + world.revoke_writer(foo_selector, 69.try_into().unwrap()); + assert(!world.is_writer(foo_selector, 69.try_into().unwrap()), 'should not be writer'); +} + +#[test] +fn test_writer_not_registered_resource() { + let world = deploy_world(); + let world = world.dispatcher; + + // 42 is not a registered resource ID + !world.is_writer(42, 69.try_into().unwrap()); +} + +#[test] +#[should_panic(expected: 'CONTRACT_NOT_DEPLOYED')] +fn test_grant_writer_through_malicious_contract() { + let (world, foo_selector) = deploy_world_and_foo(); + let world = world.dispatcher; + + let alice = starknet::contract_address_const::<0xa11ce>(); + let bob = starknet::contract_address_const::<0xb0b>(); + let malicious_contract = starknet::contract_address_const::<0xdead>(); + + world.grant_owner(foo_selector, alice); + + starknet::testing::set_account_contract_address(alice); + starknet::testing::set_contract_address(malicious_contract); + + world.grant_writer(foo_selector, bob); +} + +#[test] +#[should_panic( + expected: "Account `659918` does NOT have OWNER role on model (or its namespace) `Foo`" +)] +fn test_grant_writer_fails_for_non_owner() { + let (world, foo_selector) = deploy_world_and_foo(); + let world = world.dispatcher; + + let alice = starknet::contract_address_const::<0xa11ce>(); + let bob = starknet::contract_address_const::<0xb0b>(); + + starknet::testing::set_account_contract_address(alice); + starknet::testing::set_contract_address(alice); + + world.grant_writer(foo_selector, bob); +} + +#[test] +#[should_panic(expected: 'CONTRACT_NOT_DEPLOYED')] +fn test_revoke_writer_through_malicious_contract() { + let (world, foo_selector) = deploy_world_and_foo(); + let world = world.dispatcher; + + let alice = starknet::contract_address_const::<0xa11ce>(); + let bob = starknet::contract_address_const::<0xb0b>(); + let malicious_contract = starknet::contract_address_const::<0xdead>(); + + world.grant_owner(foo_selector, alice); + world.grant_writer(foo_selector, bob); + + starknet::testing::set_account_contract_address(alice); + starknet::testing::set_contract_address(malicious_contract); + + world.revoke_writer(foo_selector, bob); +} + +#[test] +#[should_panic( + expected: "Account `659918` does NOT have OWNER role on model (or its namespace) `Foo`" +)] +fn test_revoke_writer_fails_for_non_owner() { + let (world, foo_selector) = deploy_world_and_foo(); + let world = world.dispatcher; + + let alice = starknet::contract_address_const::<0xa11ce>(); + let bob = starknet::contract_address_const::<0xb0b>(); + + world.grant_owner(foo_selector, bob); + + starknet::testing::set_account_contract_address(alice); + starknet::testing::set_contract_address(alice); + + world.revoke_writer(foo_selector, bob); +} + +#[test] +#[should_panic( + expected: "Contract `foo_setter` does NOT have WRITER role on model (or its namespace) `Foo`" +)] +fn test_not_writer_with_known_contract() { + let (world, _) = deploy_world_and_foo(); + let world = world.dispatcher; + + let account = starknet::contract_address_const::<0xb0b>(); + world.grant_owner(bytearray_hash(@"dojo"), account); + + // the account owns the 'test_contract' namespace so it should be able to deploy + // and register the model. + starknet::testing::set_account_contract_address(account); + starknet::testing::set_contract_address(account); + + let contract_address = world + .register_contract('salt1', "dojo", foo_setter::TEST_CLASS_HASH.try_into().unwrap()); + + let d = IFooSetterDispatcher { contract_address }; + d.set_foo(1, 2); + + core::panics::panic_with_byte_array( + @"Contract `dojo-foo_setter` does NOT have WRITER role on model (or its namespace) `Foo`" + ); +} + +/// Test that an attacker can't control the hashes of resources in other namespaces +/// by registering a model in an other namespace. +#[test] +#[should_panic( + expected: "Account `7022365680606078322` does NOT have OWNER role on namespace `dojo`" +)] +fn test_register_model_namespace_not_owner() { + let owner = starknet::contract_address_const::<'owner'>(); + let attacker = starknet::contract_address_const::<'attacker'>(); + + starknet::testing::set_account_contract_address(owner); + starknet::testing::set_contract_address(owner); + + // Owner deploys the world and register Foo model. + let (world, foo_selector) = deploy_world_and_foo(); + let world = world.dispatcher; + + assert(world.is_owner(foo_selector, owner), 'should be owner'); + + starknet::testing::set_contract_address(attacker); + starknet::testing::set_account_contract_address(attacker); + + // Attacker has control over the this namespace. + world.register_namespace("atk"); + + // Attacker can't take ownership of the Foo model in the dojo namespace. + world.register_model("dojo", attacker_model::TEST_CLASS_HASH.try_into().unwrap()); +} + +/// Test that an attacker can't control the hashes of resources in other namespaces +/// by deploying a contract in an other namespace. +#[test] +#[should_panic( + expected: "Account `7022365680606078322` does NOT have OWNER role on namespace `dojo`" +)] +fn test_register_contract_namespace_not_owner() { + let owner = starknet::contract_address_const::<'owner'>(); + let attacker = starknet::contract_address_const::<'attacker'>(); + + starknet::testing::set_account_contract_address(owner); + starknet::testing::set_contract_address(owner); + + // Owner deploys the world and register Foo model. + let (world, foo_selector) = deploy_world_and_foo(); + let world = world.dispatcher; + + assert(world.is_owner(foo_selector, owner), 'should be owner'); + + starknet::testing::set_contract_address(attacker); + starknet::testing::set_account_contract_address(attacker); + + // Attacker has control over the this namespace. + world.register_namespace("atk"); + + // Attacker can't take ownership of the Foo model. + world + .register_contract('salt1', "dojo", attacker_contract::TEST_CLASS_HASH.try_into().unwrap()); +} diff --git a/crates/dojo/core-foundry-test/src/tests/world/contract.cairo b/crates/dojo/core-foundry-test/src/tests/world/contract.cairo new file mode 100644 index 0000000000..00113213f4 --- /dev/null +++ b/crates/dojo/core-foundry-test/src/tests/world/contract.cairo @@ -0,0 +1,369 @@ +use core::starknet::{ContractAddress, ClassHash}; +use dojo::world::{world, IWorldDispatcherTrait}; +use dojo::contract::components::upgradeable::{IUpgradeableDispatcher, IUpgradeableDispatcherTrait}; +use dojo::meta::{IDeployedResourceDispatcher, IDeployedResourceDispatcherTrait}; +use crate::tests::helpers::{DOJO_NSH, test_contract, drop_all_events, deploy_world}; + +#[starknet::contract] +pub mod contract_invalid_upgrade { + #[storage] + struct Storage {} + + #[abi(per_item)] + #[generate_trait] + pub impl InvalidImpl of InvalidContractTrait { + #[external(v0)] + fn no_dojo_name(self: @ContractState) -> ByteArray { + "test_contract" + } + } +} + +#[starknet::interface] +pub trait IQuantumLeap { + fn plz_more_tps(self: @T) -> felt252; +} + +#[starknet::contract] +pub mod test_contract_upgrade { + use dojo::world::IWorldDispatcher; + use dojo::contract::components::world_provider::IWorldProvider; + + #[storage] + struct Storage {} + + #[constructor] + fn constructor(ref self: ContractState) {} + + #[abi(embed_v0)] + pub impl QuantumLeap of super::IQuantumLeap { + fn plz_more_tps(self: @ContractState) -> felt252 { + 'daddy' + } + } + + #[abi(embed_v0)] + pub impl WorldProviderImpl of IWorldProvider { + fn world_dispatcher(self: @ContractState) -> IWorldDispatcher { + IWorldDispatcher { contract_address: starknet::contract_address_const::<'world'>() } + } + } + + #[abi(embed_v0)] + pub impl ContractImpl of dojo::meta::interface::IDeployedResource { + fn dojo_name(self: @ContractState) -> ByteArray { + "test_contract" + } + } +} + +#[test] +#[available_gas(7000000)] +fn test_upgrade_from_world() { + let world = deploy_world(); + let world = world.dispatcher; + + let base_address = world + .register_contract('salt', "dojo", test_contract::TEST_CLASS_HASH.try_into().unwrap()); + let new_class_hash: ClassHash = test_contract_upgrade::TEST_CLASS_HASH.try_into().unwrap(); + + world.upgrade_contract("dojo", new_class_hash); + + let quantum_dispatcher = IQuantumLeapDispatcher { contract_address: base_address }; + assert(quantum_dispatcher.plz_more_tps() == 'daddy', 'quantum leap failed'); +} + +#[test] +#[available_gas(7000000)] +#[should_panic(expected: ('ENTRYPOINT_NOT_FOUND', 'ENTRYPOINT_FAILED'))] +fn test_upgrade_from_world_not_world_provider() { + let world = deploy_world(); + let world = world.dispatcher; + + let _ = world + .register_contract('salt', "dojo", test_contract::TEST_CLASS_HASH.try_into().unwrap()); + let new_class_hash: ClassHash = contract_invalid_upgrade::TEST_CLASS_HASH.try_into().unwrap(); + + world.upgrade_contract("dojo", new_class_hash); +} + +#[test] +#[available_gas(6000000)] +#[should_panic(expected: ('must be called by world', 'ENTRYPOINT_FAILED'))] +fn test_upgrade_direct() { + let world = deploy_world(); + let world = world.dispatcher; + + let base_address = world + .register_contract('salt', "dojo", test_contract::TEST_CLASS_HASH.try_into().unwrap()); + let new_class_hash: ClassHash = test_contract_upgrade::TEST_CLASS_HASH.try_into().unwrap(); + + let upgradeable_dispatcher = IUpgradeableDispatcher { contract_address: base_address }; + upgradeable_dispatcher.upgrade(new_class_hash); +} + +#[starknet::interface] +trait IMetadataOnly { + fn dojo_name(self: @T) -> ByteArray; +} + +#[starknet::contract] +mod invalid_legacy_model { + #[storage] + struct Storage {} + + #[abi(embed_v0)] + impl InvalidModelMetadata of super::IMetadataOnly { + fn dojo_name(self: @ContractState) -> ByteArray { + "invalid_legacy_model" + } + } +} + +#[starknet::contract] +mod invalid_legacy_model_world { + #[storage] + struct Storage {} + + #[abi(embed_v0)] + impl InvalidModelName of super::IMetadataOnly { + fn dojo_name(self: @ContractState) -> ByteArray { + "invalid_legacy_model" + } + } +} + +#[starknet::contract] +mod invalid_model { + #[storage] + struct Storage {} + + #[abi(embed_v0)] + impl InvalidModelSelector of super::IMetadataOnly { + fn dojo_name(self: @ContractState) -> ByteArray { + "invalid_model" + } + } +} + +#[starknet::contract] +mod invalid_model_world { + #[storage] + struct Storage {} + + #[abi(embed_v0)] + impl InvalidModelSelector of super::IMetadataOnly { + fn dojo_name(self: @ContractState) -> ByteArray { + "invalid_model_world" + } + } +} + +#[test] +fn test_deploy_contract_for_namespace_owner() { + let world = deploy_world(); + let world = world.dispatcher; + + let class_hash = test_contract::TEST_CLASS_HASH.try_into().unwrap(); + + let bob = starknet::contract_address_const::<0xb0b>(); + world.grant_owner(DOJO_NSH, bob); + + // the account owns the 'test_contract' namespace so it should be able to deploy the contract. + starknet::testing::set_account_contract_address(bob); + starknet::testing::set_contract_address(bob); + + drop_all_events(world.contract_address); + + let contract_address = world.register_contract('salt1', "dojo", class_hash); + + let event = match starknet::testing::pop_log::(world.contract_address).unwrap() { + world::Event::ContractRegistered(event) => event, + _ => panic!("no ContractRegistered event"), + }; + + let contract = IDeployedResourceDispatcher { contract_address }; + let contract_name = contract.dojo_name(); + + assert(event.name == contract_name, 'bad name'); + assert(event.namespace == "dojo", 'bad namespace'); + assert(event.salt == 'salt1', 'bad event salt'); + assert(event.class_hash == class_hash, 'bad class_hash'); + assert( + event.address != core::num::traits::Zero::::zero(), 'bad contract address' + ); +} + +#[test] +#[should_panic(expected: "Account `2827` does NOT have OWNER role on namespace `dojo`")] +fn test_deploy_contract_for_namespace_writer() { + let world = deploy_world(); + let world = world.dispatcher; + + let bob = starknet::contract_address_const::<0xb0b>(); + world.grant_writer(DOJO_NSH, bob); + + // the account has write access to the 'test_contract' namespace so it should be able to deploy + // the contract. + starknet::testing::set_account_contract_address(bob); + starknet::testing::set_contract_address(bob); + + world.register_contract('salt1', "dojo", test_contract::TEST_CLASS_HASH.try_into().unwrap()); +} + +#[test] +#[should_panic(expected: "Account `2827` does NOT have OWNER role on namespace `dojo`")] +fn test_deploy_contract_no_namespace_owner_access() { + let world = deploy_world(); + let world = world.dispatcher; + + let bob = starknet::contract_address_const::<0xb0b>(); + starknet::testing::set_account_contract_address(bob); + starknet::testing::set_contract_address(bob); + + world.register_contract('salt1', "dojo", test_contract::TEST_CLASS_HASH.try_into().unwrap()); +} + +#[test] +#[should_panic(expected: "Namespace `buzz_namespace` is not registered")] +fn test_deploy_contract_with_unregistered_namespace() { + let world = deploy_world(); + let world = world.dispatcher; + + world + .register_contract( + 'salt1', "buzz_namespace", test_contract::TEST_CLASS_HASH.try_into().unwrap() + ); +} + +// It's CONTRACT_NOT_DEPLOYED for now as in this example the contract is not a dojo contract +// and it's not the account that is calling the deploy_contract function. +#[test] +#[should_panic(expected: 'CONTRACT_NOT_DEPLOYED')] +fn test_deploy_contract_through_malicious_contract() { + let world = deploy_world(); + let world = world.dispatcher; + + let bob = starknet::contract_address_const::<0xb0b>(); + let malicious_contract = starknet::contract_address_const::<0xdead>(); + + world.grant_owner(DOJO_NSH, bob); + + // the account owns the 'test_contract' namespace so it should be able to deploy the contract. + starknet::testing::set_account_contract_address(bob); + starknet::testing::set_contract_address(malicious_contract); + + world.register_contract('salt1', "dojo", test_contract::TEST_CLASS_HASH.try_into().unwrap()); +} +#[test] +fn test_upgrade_contract_from_resource_owner() { + let world = deploy_world(); + let world = world.dispatcher; + + let class_hash = test_contract::TEST_CLASS_HASH.try_into().unwrap(); + + let bob = starknet::contract_address_const::<0xb0b>(); + + world.grant_owner(DOJO_NSH, bob); + + starknet::testing::set_account_contract_address(bob); + starknet::testing::set_contract_address(bob); + + let contract_address = world.register_contract('salt1', "dojo", class_hash); + let contract = IDeployedResourceDispatcher { contract_address }; + let contract_name = contract.dojo_name(); + + drop_all_events(world.contract_address); + + world.upgrade_contract("dojo", class_hash); + + let event = starknet::testing::pop_log::(world.contract_address); + assert(event.is_some(), 'no event)'); + + if let world::Event::ContractUpgraded(event) = event.unwrap() { + assert( + event + .selector == dojo::utils::selector_from_namespace_and_name( + DOJO_NSH, @contract_name + ), + 'bad contract selector' + ); + assert(event.class_hash == class_hash, 'bad class_hash'); + } else { + core::panic_with_felt252('no ContractUpgraded event'); + }; +} + +#[test] +#[should_panic( + expected: "Account `659918` does NOT have OWNER role on contract (or its namespace) `test_contract`" +)] +fn test_upgrade_contract_from_resource_writer() { + let world = deploy_world(); + let world = world.dispatcher; + + let class_hash = test_contract::TEST_CLASS_HASH.try_into().unwrap(); + + let bob = starknet::contract_address_const::<0xb0b>(); + let alice = starknet::contract_address_const::<0xa11ce>(); + + world.grant_owner(DOJO_NSH, bob); + + starknet::testing::set_account_contract_address(bob); + starknet::testing::set_contract_address(bob); + + let contract_address = world.register_contract('salt1', "dojo", class_hash); + let contract = IDeployedResourceDispatcher { contract_address }; + let contract_name = contract.dojo_name(); + let contract_selector = dojo::utils::selector_from_namespace_and_name(DOJO_NSH, @contract_name); + + world.grant_writer(contract_selector, alice); + + starknet::testing::set_account_contract_address(alice); + starknet::testing::set_contract_address(alice); + + world.upgrade_contract("dojo", class_hash); +} + +#[test] +#[should_panic( + expected: "Account `659918` does NOT have OWNER role on contract (or its namespace) `test_contract`" +)] +fn test_upgrade_contract_from_random_account() { + let world = deploy_world(); + let world = world.dispatcher; + + let class_hash = test_contract::TEST_CLASS_HASH.try_into().unwrap(); + + let _contract_address = world.register_contract('salt1', "dojo", class_hash); + + let alice = starknet::contract_address_const::<0xa11ce>(); + + starknet::testing::set_account_contract_address(alice); + starknet::testing::set_contract_address(alice); + + world.upgrade_contract("dojo", class_hash); +} + +#[test] +#[should_panic(expected: 'CONTRACT_NOT_DEPLOYED')] +fn test_upgrade_contract_through_malicious_contract() { + let world = deploy_world(); + let world = world.dispatcher; + + let class_hash = test_contract::TEST_CLASS_HASH.try_into().unwrap(); + + let bob = starknet::contract_address_const::<0xb0b>(); + let malicious_contract = starknet::contract_address_const::<0xdead>(); + + world.grant_owner(DOJO_NSH, bob); + + starknet::testing::set_account_contract_address(bob); + starknet::testing::set_contract_address(bob); + + let _contract_address = world.register_contract('salt1', "dojo", class_hash); + + starknet::testing::set_contract_address(malicious_contract); + + world.upgrade_contract("dojo", class_hash); +} diff --git a/crates/dojo/core-foundry-test/src/tests/world/event.cairo b/crates/dojo/core-foundry-test/src/tests/world/event.cairo new file mode 100644 index 0000000000..bab26bc5b0 --- /dev/null +++ b/crates/dojo/core-foundry-test/src/tests/world/event.cairo @@ -0,0 +1,264 @@ +use core::starknet::ContractAddress; + +use crate::tests::helpers::{ + SimpleEvent, e_SimpleEvent, DOJO_NSH, e_FooEventBadLayoutType, drop_all_events, deploy_world, + deploy_world_for_event_upgrades +}; +use dojo::world::{world, IWorldDispatcherTrait}; +use dojo::event::Event; + +#[derive(Copy, Drop, Serde, Debug)] +#[dojo::event] +pub struct FooEventMemberRemoved { + #[key] + pub caller: ContractAddress, + pub b: u128, +} + +#[derive(Copy, Drop, Serde, Debug)] +#[dojo::event] +pub struct FooEventMemberAddedButRemoved { + #[key] + pub caller: ContractAddress, + pub b: u128, + pub c: u256, + pub d: u256 +} + +#[derive(Copy, Drop, Serde, Debug)] +#[dojo::event] +pub struct FooEventMemberAddedButMoved { + #[key] + pub caller: ContractAddress, + pub b: u128, + pub a: felt252, + pub c: u256 +} + +#[derive(Copy, Drop, Serde, Debug)] +#[dojo::event] +pub struct FooEventMemberAdded { + #[key] + pub caller: ContractAddress, + pub a: felt252, + pub b: u128, + pub c: u256 +} + +#[test] +fn test_register_event_for_namespace_owner() { + let bob = starknet::contract_address_const::<0xb0b>(); + + let world = deploy_world(); + let world = world.dispatcher; + + world.grant_owner(DOJO_NSH, bob); + + drop_all_events(world.contract_address); + + starknet::testing::set_account_contract_address(bob); + starknet::testing::set_contract_address(bob); + world.register_event("dojo", e_SimpleEvent::TEST_CLASS_HASH.try_into().unwrap()); + + let event = starknet::testing::pop_log::(world.contract_address); + assert(event.is_some(), 'no event)'); + + if let world::Event::EventRegistered(event) = event.unwrap() { + assert(event.name == Event::::name(), 'bad event name'); + assert(event.namespace == "dojo", 'bad event namespace'); + assert( + event.class_hash == e_SimpleEvent::TEST_CLASS_HASH.try_into().unwrap(), + 'bad event class_hash' + ); + assert( + event.address != core::num::traits::Zero::::zero(), + 'bad event prev address' + ); + } else { + core::panic_with_felt252('no EventRegistered event'); + } + + assert(world.is_owner(Event::::selector(DOJO_NSH), bob), 'bob is not the owner'); +} + +#[test] +#[should_panic(expected: "Account `2827` does NOT have OWNER role on namespace `dojo`")] +fn test_register_event_for_namespace_writer() { + let bob = starknet::contract_address_const::<0xb0b>(); + + let world = deploy_world(); + let world = world.dispatcher; + + world.grant_writer(DOJO_NSH, bob); + + drop_all_events(world.contract_address); + + starknet::testing::set_account_contract_address(bob); + starknet::testing::set_contract_address(bob); + world.register_event("dojo", e_SimpleEvent::TEST_CLASS_HASH.try_into().unwrap()); +} + +#[test] +fn test_upgrade_event_from_event_owner() { + let bob = starknet::contract_address_const::<0xb0b>(); + + let world = deploy_world_for_event_upgrades(); + world.grant_owner(Event::::selector(DOJO_NSH), bob); + + starknet::testing::set_account_contract_address(bob); + starknet::testing::set_contract_address(bob); + + drop_all_events(world.contract_address); + + world.upgrade_event("dojo", e_FooEventMemberAdded::TEST_CLASS_HASH.try_into().unwrap()); + + let event = starknet::testing::pop_log::(world.contract_address); + assert(event.is_some(), 'no event)'); + + if let world::Event::EventUpgraded(event) = event.unwrap() { + assert( + event.selector == Event::::selector(DOJO_NSH), 'bad model selector' + ); + assert( + event.class_hash == e_FooEventMemberAdded::TEST_CLASS_HASH.try_into().unwrap(), + 'bad model class_hash' + ); + assert( + event.address != core::num::traits::Zero::::zero(), + 'bad model prev address' + ); + } else { + core::panic_with_felt252('no EventUpgraded event'); + } + + assert( + world.is_owner(Event::::selector(DOJO_NSH), bob), + 'bob is not the owner' + ); +} + +#[test] +fn test_upgrade_event() { + let world = deploy_world_for_event_upgrades(); + + drop_all_events(world.contract_address); + + world.upgrade_event("dojo", e_FooEventMemberAdded::TEST_CLASS_HASH.try_into().unwrap()); + + let event = starknet::testing::pop_log::(world.contract_address); + assert(event.is_some(), 'no event)'); + + if let world::Event::EventUpgraded(event) = event.unwrap() { + assert( + event.selector == Event::::selector(DOJO_NSH), 'bad model selector' + ); + assert( + event.class_hash == e_FooEventMemberAdded::TEST_CLASS_HASH.try_into().unwrap(), + 'bad model class_hash' + ); + assert( + event.address != core::num::traits::Zero::::zero(), 'bad model address' + ); + } else { + core::panic_with_felt252('no EventUpgraded event'); + } +} + +#[test] +#[should_panic(expected: "Invalid new layout to upgrade the resource `dojo-FooEventBadLayoutType`")] +fn test_upgrade_event_with_bad_layout_type() { + let world = deploy_world_for_event_upgrades(); + world.upgrade_event("dojo", e_FooEventBadLayoutType::TEST_CLASS_HASH.try_into().unwrap()); +} + +#[test] +#[should_panic(expected: "Invalid new schema to upgrade the resource `dojo-FooEventMemberRemoved`")] +fn test_upgrade_event_with_member_removed() { + let world = deploy_world_for_event_upgrades(); + world.upgrade_event("dojo", e_FooEventMemberRemoved::TEST_CLASS_HASH.try_into().unwrap()); +} + +#[test] +#[should_panic( + expected: "Invalid new schema to upgrade the resource `dojo-FooEventMemberAddedButRemoved`" +)] +fn test_upgrade_event_with_member_added_but_removed() { + let world = deploy_world_for_event_upgrades(); + world + .upgrade_event( + "dojo", e_FooEventMemberAddedButRemoved::TEST_CLASS_HASH.try_into().unwrap() + ); +} + +#[test] +#[should_panic( + expected: "Invalid new schema to upgrade the resource `dojo-FooEventMemberAddedButMoved`" +)] +fn test_upgrade_event_with_member_moved() { + let world = deploy_world_for_event_upgrades(); + world.upgrade_event("dojo", e_FooEventMemberAddedButMoved::TEST_CLASS_HASH.try_into().unwrap()); +} + +#[test] +#[should_panic( + expected: "Account `659918` does NOT have OWNER role on event (or its namespace) `FooEventMemberAdded`" +)] +fn test_upgrade_event_from_event_writer() { + let alice = starknet::contract_address_const::<0xa11ce>(); + + let world = deploy_world_for_event_upgrades(); + + world.grant_writer(Event::::selector(DOJO_NSH), alice); + + starknet::testing::set_account_contract_address(alice); + starknet::testing::set_contract_address(alice); + world.upgrade_event("dojo", e_FooEventMemberAdded::TEST_CLASS_HASH.try_into().unwrap()); +} + +#[test] +#[should_panic(expected: "Resource `dojo-SimpleEvent` is already registered")] +fn test_upgrade_event_from_random_account() { + let bob = starknet::contract_address_const::<0xb0b>(); + let alice = starknet::contract_address_const::<0xa11ce>(); + + let world = deploy_world(); + let world = world.dispatcher; + + world.grant_owner(DOJO_NSH, bob); + world.grant_owner(DOJO_NSH, alice); + + starknet::testing::set_account_contract_address(bob); + starknet::testing::set_contract_address(bob); + world.register_event("dojo", e_SimpleEvent::TEST_CLASS_HASH.try_into().unwrap()); + + starknet::testing::set_account_contract_address(alice); + starknet::testing::set_contract_address(alice); + world.register_event("dojo", e_SimpleEvent::TEST_CLASS_HASH.try_into().unwrap()); +} + +#[test] +#[should_panic(expected: "Namespace `another_namespace` is not registered")] +fn test_register_event_with_unregistered_namespace() { + let world = deploy_world(); + let world = world.dispatcher; + + world.register_event("another_namespace", e_SimpleEvent::TEST_CLASS_HASH.try_into().unwrap()); +} + +// It's CONTRACT_NOT_DEPLOYED for now as in this example the contract is not a dojo contract +// and it's not the account that is calling the register_event function. +#[test] +#[should_panic(expected: 'CONTRACT_NOT_DEPLOYED')] +fn test_register_event_through_malicious_contract() { + let bob = starknet::contract_address_const::<0xb0b>(); + let malicious_contract = starknet::contract_address_const::<0xdead>(); + + let world = deploy_world(); + let world = world.dispatcher; + + world.grant_owner(DOJO_NSH, bob); + + starknet::testing::set_account_contract_address(bob); + starknet::testing::set_contract_address(malicious_contract); + world.register_event("dojo", e_SimpleEvent::TEST_CLASS_HASH.try_into().unwrap()); +} diff --git a/crates/dojo/core-foundry-test/src/tests/world/metadata.cairo b/crates/dojo/core-foundry-test/src/tests/world/metadata.cairo new file mode 100644 index 0000000000..4e17c09546 --- /dev/null +++ b/crates/dojo/core-foundry-test/src/tests/world/metadata.cairo @@ -0,0 +1,114 @@ +use dojo::world::{world, IWorldDispatcherTrait}; +use dojo::model::{Model, ResourceMetadata}; + +use crate::tests::helpers::{DOJO_NSH, Foo, drop_all_events, deploy_world, deploy_world_and_foo}; + +#[test] +fn test_set_metadata_world() { + let world = deploy_world(); + let world = world.dispatcher; + + let metadata = ResourceMetadata { + resource_id: 0, metadata_uri: format!("ipfs:world_with_a_long_uri_that") + }; + + world.set_metadata(metadata.clone()); + + assert(world.metadata(0) == metadata, 'invalid metadata'); +} + +#[test] +fn test_set_metadata_resource_owner() { + let (world, model_selector) = deploy_world_and_foo(); + let world = world.dispatcher; + + let bob = starknet::contract_address_const::<0xb0b>(); + + world.grant_owner(Model::::selector(DOJO_NSH), bob); + + starknet::testing::set_account_contract_address(bob); + starknet::testing::set_contract_address(bob); + + let metadata = ResourceMetadata { + resource_id: model_selector, metadata_uri: format!("ipfs:bob") + }; + + drop_all_events(world.contract_address); + + // Metadata must be updated by a direct call from an account which has owner role + // for the attached resource. + world.set_metadata(metadata.clone()); + assert(world.metadata(model_selector) == metadata, 'bad metadata'); + + let event = starknet::testing::pop_log::(world.contract_address); + assert(event.is_some(), 'no event)'); + + if let world::Event::MetadataUpdate(event) = event.unwrap() { + assert(event.resource == metadata.resource_id, 'bad resource'); + assert(event.uri == metadata.metadata_uri, 'bad uri'); + } else { + core::panic_with_felt252('no EventUpgraded event'); + } +} + +#[test] +#[should_panic( + expected: "Account `2827` does NOT have OWNER role on model (or its namespace) `Foo`" +)] +fn test_set_metadata_not_possible_for_resource_writer() { + let (world, model_selector) = deploy_world_and_foo(); + let world = world.dispatcher; + + let bob = starknet::contract_address_const::<0xb0b>(); + + world.grant_writer(model_selector, bob); + + starknet::testing::set_account_contract_address(bob); + starknet::testing::set_contract_address(bob); + + let metadata = ResourceMetadata { + resource_id: model_selector, metadata_uri: format!("ipfs:bob") + }; + + world.set_metadata(metadata.clone()); +} + +#[test] +#[should_panic(expected: "Account `2827` does NOT have OWNER role on world")] +fn test_set_metadata_not_possible_for_random_account() { + let world = deploy_world(); + let world = world.dispatcher; + + let metadata = ResourceMetadata { // World metadata. + resource_id: 0, metadata_uri: format!("ipfs:bob"), + }; + + let bob = starknet::contract_address_const::<0xb0b>(); + starknet::testing::set_contract_address(bob); + starknet::testing::set_account_contract_address(bob); + + // Bob access follows the conventional ACL, he can't write the world + // metadata if he does not have access to it. + world.set_metadata(metadata); +} + +#[test] +#[should_panic(expected: ('CONTRACT_NOT_DEPLOYED', 'ENTRYPOINT_FAILED',))] +fn test_set_metadata_through_malicious_contract() { + let (world, model_selector) = deploy_world_and_foo(); + let world = world.dispatcher; + + let bob = starknet::contract_address_const::<0xb0b>(); + let malicious_contract = starknet::contract_address_const::<0xdead>(); + + world.grant_owner(model_selector, bob); + + starknet::testing::set_account_contract_address(bob); + starknet::testing::set_contract_address(malicious_contract); + + let metadata = ResourceMetadata { + resource_id: model_selector, metadata_uri: format!("ipfs:bob") + }; + + world.set_metadata(metadata.clone()); +} diff --git a/crates/dojo/core-foundry-test/src/tests/world/model.cairo b/crates/dojo/core-foundry-test/src/tests/world/model.cairo new file mode 100644 index 0000000000..5616e1cfb6 --- /dev/null +++ b/crates/dojo/core-foundry-test/src/tests/world/model.cairo @@ -0,0 +1,285 @@ +use core::starknet::ContractAddress; + +use crate::tests::helpers::{ + Foo, m_Foo, DOJO_NSH, drop_all_events, deploy_world, deploy_world_for_model_upgrades, + foo_invalid_name +}; +use dojo::world::{world, IWorldDispatcherTrait}; +use dojo::model::Model; + + +#[derive(Introspect, Copy, Drop, Serde)] +#[dojo::model] +pub struct FooModelBadLayoutType { + #[key] + pub caller: ContractAddress, + pub a: felt252, + pub b: u128, +} + +#[derive(Introspect, Copy, Drop, Serde)] +#[dojo::model] +pub struct FooModelMemberRemoved { + #[key] + pub caller: ContractAddress, + pub b: u128, +} + +#[derive(Introspect, Copy, Drop, Serde)] +#[dojo::model] +pub struct FooModelMemberAddedButRemoved { + #[key] + pub caller: ContractAddress, + pub b: u128, + pub c: u256, + pub d: u256 +} + +#[derive(Introspect, Copy, Drop, Serde)] +#[dojo::model] +pub struct FooModelMemberAddedButMoved { + #[key] + pub caller: ContractAddress, + pub b: u128, + pub a: felt252, + pub c: u256 +} + +#[derive(Introspect, Copy, Drop, Serde)] +#[dojo::model] +pub struct FooModelMemberAdded { + #[key] + pub caller: ContractAddress, + pub a: felt252, + pub b: u128, + pub c: u256 +} + +#[test] +fn test_register_model_for_namespace_owner() { + let bob = starknet::contract_address_const::<0xb0b>(); + + let world = deploy_world(); + let world = world.dispatcher; + + world.grant_owner(DOJO_NSH, bob); + + drop_all_events(world.contract_address); + + starknet::testing::set_account_contract_address(bob); + starknet::testing::set_contract_address(bob); + world.register_model("dojo", m_Foo::TEST_CLASS_HASH.try_into().unwrap()); + + let event = starknet::testing::pop_log::(world.contract_address); + assert(event.is_some(), 'no event)'); + + if let world::Event::ModelRegistered(event) = event.unwrap() { + assert(event.name == Model::::name(), 'bad event name'); + assert(event.namespace == "dojo", 'bad event namespace'); + assert( + event.class_hash == m_Foo::TEST_CLASS_HASH.try_into().unwrap(), 'bad event class_hash' + ); + assert( + event.address != core::num::traits::Zero::::zero(), + 'bad event prev address' + ); + } else { + core::panic_with_felt252('no ModelRegistered event'); + } + + assert(world.is_owner(Model::::selector(DOJO_NSH), bob), 'bob is not the owner'); +} + + +#[test] +#[should_panic( + expected: "Name `foo-bis` is invalid according to Dojo naming rules: ^[a-zA-Z0-9_]+$" +)] +fn test_register_model_with_invalid_name() { + let world = deploy_world(); + let world = world.dispatcher; + + world.register_model("dojo", foo_invalid_name::TEST_CLASS_HASH.try_into().unwrap()); +} + +#[test] +#[should_panic(expected: "Account `2827` does NOT have OWNER role on namespace `dojo`")] +fn test_register_model_for_namespace_writer() { + let bob = starknet::contract_address_const::<0xb0b>(); + + let world = deploy_world(); + let world = world.dispatcher; + + world.grant_writer(DOJO_NSH, bob); + + drop_all_events(world.contract_address); + + starknet::testing::set_account_contract_address(bob); + starknet::testing::set_contract_address(bob); + world.register_model("dojo", m_Foo::TEST_CLASS_HASH.try_into().unwrap()); +} + +#[test] +fn test_upgrade_model_from_model_owner() { + let bob = starknet::contract_address_const::<0xb0b>(); + + let world = deploy_world_for_model_upgrades(); + world.grant_owner(Model::::selector(DOJO_NSH), bob); + + starknet::testing::set_account_contract_address(bob); + starknet::testing::set_contract_address(bob); + + drop_all_events(world.contract_address); + + world.upgrade_model("dojo", m_FooModelMemberAdded::TEST_CLASS_HASH.try_into().unwrap()); + + let event = starknet::testing::pop_log::(world.contract_address); + assert(event.is_some(), 'no event)'); + + if let world::Event::ModelUpgraded(event) = event.unwrap() { + assert( + event.selector == Model::::selector(DOJO_NSH), 'bad model selector' + ); + assert( + event.class_hash == m_FooModelMemberAdded::TEST_CLASS_HASH.try_into().unwrap(), + 'bad model class_hash' + ); + assert( + event.address != core::num::traits::Zero::::zero(), + 'bad model prev address' + ); + } else { + core::panic_with_felt252('no ModelUpgraded event'); + } + + assert( + world.is_owner(Model::::selector(DOJO_NSH), bob), + 'bob is not the owner' + ); +} + +#[test] +fn test_upgrade_model() { + let world = deploy_world_for_model_upgrades(); + + drop_all_events(world.contract_address); + + world.upgrade_model("dojo", m_FooModelMemberAdded::TEST_CLASS_HASH.try_into().unwrap()); + + let event = starknet::testing::pop_log::(world.contract_address); + assert(event.is_some(), 'no event)'); + + if let world::Event::ModelUpgraded(event) = event.unwrap() { + assert( + event.selector == Model::::selector(DOJO_NSH), 'bad model selector' + ); + assert( + event.class_hash == m_FooModelMemberAdded::TEST_CLASS_HASH.try_into().unwrap(), + 'bad model class_hash' + ); + assert( + event.address != core::num::traits::Zero::::zero(), 'bad model address' + ); + } else { + core::panic_with_felt252('no ModelUpgraded event'); + } +} + +#[test] +#[should_panic(expected: "Invalid new layout to upgrade the resource `dojo-FooModelBadLayoutType`")] +fn test_upgrade_model_with_bad_layout_type() { + let world = deploy_world_for_model_upgrades(); + world.upgrade_model("dojo", m_FooModelBadLayoutType::TEST_CLASS_HASH.try_into().unwrap()); +} + +#[test] +#[should_panic(expected: "Invalid new schema to upgrade the resource `dojo-FooModelMemberRemoved`")] +fn test_upgrade_model_with_member_removed() { + let world = deploy_world_for_model_upgrades(); + world.upgrade_model("dojo", m_FooModelMemberRemoved::TEST_CLASS_HASH.try_into().unwrap()); +} + +#[test] +#[should_panic( + expected: "Invalid new schema to upgrade the resource `dojo-FooModelMemberAddedButRemoved`" +)] +fn test_upgrade_model_with_member_added_but_removed() { + let world = deploy_world_for_model_upgrades(); + world + .upgrade_model( + "dojo", m_FooModelMemberAddedButRemoved::TEST_CLASS_HASH.try_into().unwrap() + ); +} + +#[test] +#[should_panic( + expected: "Invalid new schema to upgrade the resource `dojo-FooModelMemberAddedButMoved`" +)] +fn test_upgrade_model_with_member_moved() { + let world = deploy_world_for_model_upgrades(); + world.upgrade_model("dojo", m_FooModelMemberAddedButMoved::TEST_CLASS_HASH.try_into().unwrap()); +} + +#[test] +#[should_panic( + expected: "Account `659918` does NOT have OWNER role on model (or its namespace) `FooModelMemberAdded`" +)] +fn test_upgrade_model_from_model_writer() { + let alice = starknet::contract_address_const::<0xa11ce>(); + + let world = deploy_world_for_model_upgrades(); + + world.grant_writer(Model::::selector(DOJO_NSH), alice); + + starknet::testing::set_account_contract_address(alice); + starknet::testing::set_contract_address(alice); + world.upgrade_model("dojo", m_FooModelMemberAdded::TEST_CLASS_HASH.try_into().unwrap()); +} + +#[test] +#[should_panic(expected: "Resource `dojo-Foo` is already registered")] +fn test_upgrade_model_from_random_account() { + let bob = starknet::contract_address_const::<0xb0b>(); + let alice = starknet::contract_address_const::<0xa11ce>(); + + let world = deploy_world(); + let world = world.dispatcher; + + world.grant_owner(DOJO_NSH, bob); + world.grant_owner(DOJO_NSH, alice); + + starknet::testing::set_account_contract_address(bob); + starknet::testing::set_contract_address(bob); + world.register_model("dojo", m_Foo::TEST_CLASS_HASH.try_into().unwrap()); + + starknet::testing::set_account_contract_address(alice); + starknet::testing::set_contract_address(alice); + world.register_model("dojo", m_Foo::TEST_CLASS_HASH.try_into().unwrap()); +} + +#[test] +#[should_panic(expected: "Namespace `another_namespace` is not registered")] +fn test_register_model_with_unregistered_namespace() { + let world = deploy_world(); + let world = world.dispatcher; + + world.register_model("another_namespace", m_Foo::TEST_CLASS_HASH.try_into().unwrap()); +} + +// It's CONTRACT_NOT_DEPLOYED for now as in this example the contract is not a dojo contract +// and it's not the account that is calling the register_model function. +#[test] +#[should_panic(expected: 'CONTRACT_NOT_DEPLOYED')] +fn test_register_model_through_malicious_contract() { + let bob = starknet::contract_address_const::<0xb0b>(); + let malicious_contract = starknet::contract_address_const::<0xdead>(); + + let world = deploy_world(); + let world = world.dispatcher; + + world.grant_owner(DOJO_NSH, bob); + + starknet::testing::set_account_contract_address(bob); + starknet::testing::set_contract_address(malicious_contract); + world.register_model("dojo", m_Foo::TEST_CLASS_HASH.try_into().unwrap()); +} diff --git a/crates/dojo/core-foundry-test/src/tests/world/namespace.cairo b/crates/dojo/core-foundry-test/src/tests/world/namespace.cairo new file mode 100644 index 0000000000..bce5d1f3d7 --- /dev/null +++ b/crates/dojo/core-foundry-test/src/tests/world/namespace.cairo @@ -0,0 +1,75 @@ +use dojo::world::{world, IWorldDispatcherTrait}; +use dojo::utils::bytearray_hash; + +use crate::tests::helpers::{drop_all_events, deploy_world}; + +#[test] +fn test_register_namespace() { + let world = deploy_world(); + let world = world.dispatcher; + + let bob = starknet::contract_address_const::<0xb0b>(); + starknet::testing::set_account_contract_address(bob); + starknet::testing::set_contract_address(bob); + + drop_all_events(world.contract_address); + + let namespace = "namespace"; + let hash = bytearray_hash(@namespace); + + world.register_namespace(namespace.clone()); + + assert(world.is_owner(hash, bob), 'namespace not registered'); + + match starknet::testing::pop_log::(world.contract_address).unwrap() { + world::Event::NamespaceRegistered(event) => { + assert(event.namespace == namespace, 'bad namespace'); + assert(event.hash == hash, 'bad hash'); + }, + _ => panic!("no NamespaceRegistered event"), + } +} + +#[test] +#[should_panic(expected: "Namespace `namespace` is already registered")] +fn test_register_namespace_already_registered_same_caller() { + let world = deploy_world(); + let world = world.dispatcher; + + let bob = starknet::contract_address_const::<0xb0b>(); + starknet::testing::set_account_contract_address(bob); + starknet::testing::set_contract_address(bob); + + world.register_namespace("namespace"); + world.register_namespace("namespace"); +} + +#[test] +#[should_panic(expected: "Namespace `namespace` is already registered")] +fn test_register_namespace_already_registered_other_caller() { + let world = deploy_world(); + let world = world.dispatcher; + + let bob = starknet::contract_address_const::<0xb0b>(); + starknet::testing::set_account_contract_address(bob); + starknet::testing::set_contract_address(bob); + + world.register_namespace("namespace"); + + let alice = starknet::contract_address_const::<0xa11ce>(); + starknet::testing::set_account_contract_address(alice); + starknet::testing::set_contract_address(alice); + + world.register_namespace("namespace"); +} + + +#[test] +#[available_gas(6000000)] +#[should_panic(expected: "Namespace `` is invalid according to Dojo naming rules: ^[a-zA-Z0-9_]+$")] +fn test_register_namespace_empty_name() { + let world = deploy_world(); + let world = world.dispatcher; + + world.register_namespace(""); +} diff --git a/crates/dojo/core-foundry-test/src/tests/world/storage.cairo b/crates/dojo/core-foundry-test/src/tests/world/storage.cairo new file mode 100644 index 0000000000..7b7bf07ead --- /dev/null +++ b/crates/dojo/core-foundry-test/src/tests/world/storage.cairo @@ -0,0 +1,128 @@ +use dojo::model::ModelStorage; + +use crate::tests::helpers::{deploy_world_and_foo, Foo, NotCopiable}; + +#[test] +fn write_simple() { + let (mut world, _) = deploy_world_and_foo(); + + let bob = 0xb0b.try_into().unwrap(); + + let foo: Foo = world.read_model(bob); + assert_eq!(foo.caller, bob); + assert_eq!(foo.a, 0); + assert_eq!(foo.b, 0); + + let foo = Foo { caller: bob, a: 1, b: 2 }; + world.write_model(@foo); + + let foo: Foo = world.read_model(bob); + assert_eq!(foo.caller, bob); + assert_eq!(foo.a, 1); + assert_eq!(foo.b, 2); + + world.erase_model(@foo); + + let foo: Foo = world.read_model(bob); + assert_eq!(foo.caller, bob); + assert_eq!(foo.a, 0); + assert_eq!(foo.b, 0); +} + +#[test] +fn write_multiple_copiable() { + let (mut world, _) = deploy_world_and_foo(); + + let mut models_snaps: Array<@Foo> = array![]; + let mut keys: Array = array![]; + + for i in 0_u128 + ..10_u128 { + let felt: felt252 = i.into(); + let caller: starknet::ContractAddress = felt.try_into().unwrap(); + keys.append(caller); + + if i % 2 == 0 { + let foo = Foo { caller, a: felt, b: i }; + models_snaps.append(@foo); + } else { + let foo = Foo { caller, a: felt, b: i }; + models_snaps.append(@foo); + } + }; + + world.write_models(models_snaps.span()); + + let mut models: Array = world.read_models(keys.span()); + + assert_eq!(models.len(), 10); + + for i in 0_u128 + ..10_u128 { + let felt: felt252 = i.into(); + let caller: starknet::ContractAddress = felt.try_into().unwrap(); + // Can desnap as copiable. + let model: Foo = *models[i.try_into().unwrap()]; + assert_eq!(model.caller, caller); + assert_eq!(model.a, felt); + assert_eq!(model.b, i); + }; + + world.erase_models(models_snaps.span()); + + let mut models: Array = world.read_models(keys.span()); + + while let Option::Some(m) = models.pop_front() { + assert_eq!(m.a, 0); + assert_eq!(m.b, 0); + }; +} + +#[test] +fn write_multiple_not_copiable() { + let (mut world, _) = deploy_world_and_foo(); + + let mut models_snaps: Array<@NotCopiable> = array![]; + let mut keys: Array = array![]; + + for i in 0_u128 + ..10_u128 { + let felt: felt252 = i.into(); + let caller: starknet::ContractAddress = felt.try_into().unwrap(); + keys.append(caller); + + if i % 2 == 0 { + let foo = NotCopiable { caller, a: array![felt], b: "ab" }; + models_snaps.append(@foo); + } else { + let foo = NotCopiable { caller, a: array![felt], b: "ab" }; + models_snaps.append(@foo); + } + }; + + world.write_models(models_snaps.span()); + + let mut models: Array = world.read_models(keys.span()); + + assert_eq!(models.len(), 10); + + for i in 0_u128 + ..10_u128 { + let felt: felt252 = i.into(); + let caller: starknet::ContractAddress = felt.try_into().unwrap(); + // Can desnap as copiable. + let model: NotCopiable = models.pop_front().unwrap(); + assert_eq!(model.caller, caller); + assert_eq!(model.a, array![felt]); + assert_eq!(model.b, "ab"); + }; + + world.erase_models(models_snaps.span()); + + let mut models: Array = world.read_models(keys.span()); + + while let Option::Some(m) = models.pop_front() { + assert_eq!(m.a, array![]); + assert_eq!(m.b, ""); + }; +} diff --git a/crates/dojo/core-foundry-test/src/tests/world/world.cairo b/crates/dojo/core-foundry-test/src/tests/world/world.cairo new file mode 100644 index 0000000000..21a5f86f7f --- /dev/null +++ b/crates/dojo/core-foundry-test/src/tests/world/world.cairo @@ -0,0 +1,311 @@ +use dojo::world::Resource; +use dojo::world::world::Event as WorldEvent; +use dojo::utils::bytearray_hash; +use dojo::world::{ + IWorldDispatcher, IWorldDispatcherTrait, IUpgradeableWorldDispatcher, + IUpgradeableWorldDispatcherTrait, WorldStorageTrait +}; +use dojo::model::ModelStorage; +use dojo::event::{Event, EventStorage}; + +use crate::tests::helpers::{ + IbarDispatcherTrait, drop_all_events, deploy_world_and_bar, Foo, m_Foo, test_contract, + test_contract_with_dojo_init_args, SimpleEvent, e_SimpleEvent, deploy_world +}; +use crate::{spawn_test_world, ContractDefTrait, NamespaceDef, TestResource, WorldStorageTestTrait}; + +#[test] +#[available_gas(20000000)] +fn test_model() { + let world = deploy_world(); + let world = world.dispatcher; + + world.register_model("dojo", m_Foo::TEST_CLASS_HASH.try_into().unwrap()); +} + +#[test] +fn test_system() { + let (world, bar_contract) = deploy_world_and_bar(); + + bar_contract.set_foo(1337, 1337); + + let stored: Foo = world.read_model(starknet::get_caller_address()); + assert(stored.a == 1337, 'data not stored'); + assert(stored.b == 1337, 'data not stored'); +} + +#[test] +fn test_delete() { + let (world, bar_contract) = deploy_world_and_bar(); + + bar_contract.set_foo(1337, 1337); + let stored: Foo = world.read_model(starknet::get_caller_address()); + assert(stored.a == 1337, 'data not stored'); + assert(stored.b == 1337, 'data not stored'); + + bar_contract.delete_foo(); + + let deleted: Foo = world.read_model(starknet::get_caller_address()); + assert(deleted.a == 0, 'data not deleted'); + assert(deleted.b == 0, 'data not deleted'); +} + +#[test] +#[available_gas(6000000)] +fn test_contract_getter() { + let world = deploy_world(); + let world = world.dispatcher; + + let address = world + .register_contract('salt1', "dojo", test_contract::TEST_CLASS_HASH.try_into().unwrap()); + + if let Resource::Contract((contract_address, namespace_hash)) = world + .resource(selector_from_tag!("dojo-test_contract")) { + assert(address == contract_address, 'invalid contract address'); + + assert(namespace_hash == bytearray_hash(@"dojo"), 'invalid namespace hash'); + } +} + +#[test] +#[available_gas(6000000)] +fn test_emit() { + let bob = starknet::contract_address_const::<0xb0b>(); + + let namespace_def = NamespaceDef { + namespace: "dojo", resources: [TestResource::Event(e_SimpleEvent::TEST_CLASS_HASH),].span(), + }; + + let mut world = spawn_test_world([namespace_def].span()); + + let bob_def = ContractDefTrait::new_address(bob) + .with_writer_of([world.resource_selector(@"SimpleEvent")].span()); + world.sync_perms_and_inits([bob_def].span()); + + drop_all_events(world.dispatcher.contract_address); + + starknet::testing::set_contract_address(bob); + + let simple_event = SimpleEvent { id: 2, data: (3, 4) }; + world.emit_event(@simple_event); + + let event = starknet::testing::pop_log::(world.dispatcher.contract_address); + + assert(event.is_some(), 'no event'); + + if let WorldEvent::EventEmitted(event) = event.unwrap() { + assert( + event.selector == Event::::selector(world.namespace_hash), + 'bad event selector' + ); + assert(event.system_address == bob, 'bad system address'); + assert(event.keys == [2].span(), 'bad keys'); + assert(event.values == [3, 4].span(), 'bad values'); + } else { + core::panic_with_felt252('no EventEmitted event'); + } +} + +#[test] +fn test_execute_multiple_worlds() { + let (world1, bar1_contract) = deploy_world_and_bar(); + let (world2, bar2_contract) = deploy_world_and_bar(); + + let alice = starknet::contract_address_const::<0x1337>(); + starknet::testing::set_contract_address(alice); + + bar1_contract.set_foo(1337, 1337); + bar2_contract.set_foo(7331, 7331); + + let data1: Foo = world1.read_model(alice); + let data2: Foo = world2.read_model(alice); + + assert(data1.a == 1337, 'data1 not stored'); + assert(data2.a == 7331, 'data2 not stored'); +} + +#[starknet::interface] +trait IWorldUpgrade { + fn hello(self: @TContractState) -> felt252; +} + +#[starknet::contract] +mod worldupgrade { + use super::IWorldDispatcher; + + #[storage] + struct Storage { + world: IWorldDispatcher, + } + + #[abi(embed_v0)] + impl IWorldUpgradeImpl of super::IWorldUpgrade { + fn hello(self: @ContractState) -> felt252 { + 'dojo' + } + } +} + + +#[test] +#[available_gas(60000000)] +fn test_upgradeable_world() { + let world = deploy_world(); + let world = world.dispatcher; + + let mut upgradeable_world_dispatcher = IUpgradeableWorldDispatcher { + contract_address: world.contract_address + }; + upgradeable_world_dispatcher.upgrade(worldupgrade::TEST_CLASS_HASH.try_into().unwrap()); + + let res = (IWorldUpgradeDispatcher { contract_address: world.contract_address }).hello(); + + assert(res == 'dojo', 'should return dojo'); +} + +#[test] +#[available_gas(60000000)] +#[should_panic(expected: ('invalid class_hash', 'ENTRYPOINT_FAILED'))] +fn test_upgradeable_world_with_class_hash_zero() { + let world = deploy_world(); + let world = world.dispatcher; + + let admin = starknet::contract_address_const::<0x1337>(); + starknet::testing::set_account_contract_address(admin); + starknet::testing::set_contract_address(admin); + + let mut upgradeable_world_dispatcher = IUpgradeableWorldDispatcher { + contract_address: world.contract_address + }; + upgradeable_world_dispatcher.upgrade(0.try_into().unwrap()); +} + +#[test] +#[available_gas(60000000)] +#[should_panic(expected: "Caller `4919` cannot upgrade the resource `0` (not owner)")] +fn test_upgradeable_world_from_non_owner() { + // Deploy world contract + let world = deploy_world(); + let world = world.dispatcher; + + let not_owner = starknet::contract_address_const::<0x1337>(); + starknet::testing::set_contract_address(not_owner); + starknet::testing::set_account_contract_address(not_owner); + + let mut upgradeable_world_dispatcher = IUpgradeableWorldDispatcher { + contract_address: world.contract_address + }; + upgradeable_world_dispatcher.upgrade(worldupgrade::TEST_CLASS_HASH.try_into().unwrap()); +} + +#[test] +#[available_gas(6000000)] +fn test_constructor_default() { + let world = deploy_world(); + let world = world.dispatcher; + + let _address = world + .register_contract('salt1', "dojo", test_contract::TEST_CLASS_HASH.try_into().unwrap()); +} + +#[test] +fn test_can_call_init_only_world() { + let world = deploy_world(); + let world = world.dispatcher; + + let address = world + .register_contract('salt1', "dojo", test_contract::TEST_CLASS_HASH.try_into().unwrap()); + + let expected_panic: ByteArray = + "Only the world can init contract `test_contract`, but caller is `0`"; + + match starknet::syscalls::call_contract_syscall( + address, dojo::world::world::DOJO_INIT_SELECTOR, [].span() + ) { + Result::Ok(_) => panic!("should panic"), + Result::Err(e) => { + let mut s = e.span(); + // Remove the out of range error. + s.pop_front().unwrap(); + // Remove the ENTRYPOINT_FAILED suffix. + s.pop_back().unwrap(); + + let e_str: ByteArray = Serde::deserialize(ref s).expect('failed deser'); + println!("e_str: {}", e_str); + assert_eq!(e_str, expected_panic); + } + } +} + +#[test] +#[available_gas(6000000)] +#[should_panic(expected: ('CONTRACT_NOT_DEPLOYED', 'ENTRYPOINT_FAILED'))] +fn test_can_call_init_only_owner() { + let world = deploy_world(); + let world = world.dispatcher; + + let _address = world + .register_contract('salt1', "dojo", test_contract::TEST_CLASS_HASH.try_into().unwrap()); + + let bob = starknet::contract_address_const::<0x1337>(); + starknet::testing::set_contract_address(bob); + + world.init_contract(selector_from_tag!("dojo-test_contract"), [].span()); +} + +#[test] +#[available_gas(6000000)] +fn test_can_call_init_default() { + let world = deploy_world(); + let world = world.dispatcher; + + let _address = world + .register_contract('salt1', "dojo", test_contract::TEST_CLASS_HASH.try_into().unwrap()); + + world.init_contract(selector_from_tag!("dojo-test_contract"), [].span()); +} + +#[test] +#[available_gas(6000000)] +fn test_can_call_init_args() { + let world = deploy_world(); + let world = world.dispatcher; + + let _address = world + .register_contract( + 'salt1', "dojo", test_contract_with_dojo_init_args::TEST_CLASS_HASH.try_into().unwrap() + ); + + world.init_contract(selector_from_tag!("dojo-test_contract_with_dojo_init_args"), [1].span()); +} + +#[test] +fn test_can_call_init_only_world_args() { + let world = deploy_world(); + let world = world.dispatcher; + + let address = world + .register_contract( + 'salt1', "dojo", test_contract_with_dojo_init_args::TEST_CLASS_HASH.try_into().unwrap() + ); + + let expected_panic: ByteArray = + "Only the world can init contract `test_contract_with_dojo_init_args`, but caller is `0`"; + + match starknet::syscalls::call_contract_syscall( + address, dojo::world::world::DOJO_INIT_SELECTOR, [123].span() + ) { + Result::Ok(_) => panic!("should panic"), + Result::Err(e) => { + let mut s = e.span(); + // Remove the out of range error. + s.pop_front().unwrap(); + // Remove the ENTRYPOINT_FAILED suffix. + s.pop_back().unwrap(); + + let e_str: ByteArray = Serde::deserialize(ref s).expect('failed deser'); + + assert_eq!(e_str, expected_panic); + } + } +} diff --git a/crates/dojo/core-foundry-test/src/utils.cairo b/crates/dojo/core-foundry-test/src/utils.cairo new file mode 100644 index 0000000000..051be84e2a --- /dev/null +++ b/crates/dojo/core-foundry-test/src/utils.cairo @@ -0,0 +1,57 @@ +#[derive(Drop)] +pub struct GasCounter { + pub start: u128, +} + +#[generate_trait] +pub impl GasCounterImpl of GasCounterTrait { + fn start() -> GasCounter { + let start = core::testing::get_available_gas(); + core::gas::withdraw_gas().unwrap(); + GasCounter { start } + } + + fn end(self: GasCounter, name: ByteArray) { + let end = core::testing::get_available_gas(); + let gas_used = self.start - end; + + println!("# GAS # {}: {}", Self::pad_start(name, 18), gas_used); + core::gas::withdraw_gas().unwrap(); + } + + fn pad_start(str: ByteArray, len: u32) -> ByteArray { + let mut missing: ByteArray = ""; + let missing_len = if str.len() >= len { + 0 + } else { + len - str.len() + }; + + while missing.len() < missing_len { + missing.append(@"."); + }; + missing + str + } +} + +// assert that `value` and `expected` have the same size and the same content +pub fn assert_array(value: Span, expected: Span) { + assert!(value.len() == expected.len(), "Bad array length"); + + let mut i = 0; + loop { + if i >= value.len() { + break; + } + + assert!( + *value.at(i) == *expected.at(i), + "Bad array value [{}] (expected: {} got: {})", + i, + *expected.at(i), + *value.at(i) + ); + + i += 1; + } +} diff --git a/crates/dojo/core-foundry-test/src/world.cairo b/crates/dojo/core-foundry-test/src/world.cairo new file mode 100644 index 0000000000..8e3910fc21 --- /dev/null +++ b/crates/dojo/core-foundry-test/src/world.cairo @@ -0,0 +1,224 @@ +use core::option::OptionTrait; +use core::result::ResultTrait; +use core::traits::{Into, TryInto}; + +use starknet::{ContractAddress, syscalls::deploy_syscall}; + +use dojo::world::{world, IWorldDispatcher, IWorldDispatcherTrait, WorldStorageTrait, WorldStorage}; + +pub type TestClassHash = felt252; + +/// In Cairo test runner, all the classes are expected to be declared already. +/// If a contract belong to an other crate, it must be added to the `build-external-contract`, +/// event for testing, since Scarb does not do that automatically anymore. +/// +/// The [`TestResource`] enum uses a felt252 to represent the class hash, this avoids +/// having to write `bar::TEST_CLASS_HASH.try_into().unwrap()` in the test file, simply use +/// `bar::TEST_CLASS_HASH`. +#[derive(Drop)] +pub enum TestResource { + Event: TestClassHash, + Model: TestClassHash, + Contract: TestClassHash, +} + +#[derive(Drop, Copy)] +pub enum ContractDescriptor { + /// Address of the contract. + Address: ContractAddress, + /// Namespace and name of the contract. + Named: (@ByteArray, @ByteArray), +} + +/// Definition of a contract to register in the world. +/// +/// You can use this struct for a dojo contract, but also for an external contract. +/// The only difference is the `init_calldata`, which is only used for dojo contracts. +/// If the `contract` is an external contract (hence an address), then `init_calldata` is ignored. +#[derive(Drop, Copy)] +pub struct ContractDef { + /// The contract to grant permission to. + pub contract: ContractDescriptor, + /// Selectors of the resources that the contract is granted writer access to. + pub writer_of: Span, + /// Selector of the resource that the contract is the owner of. + pub owner_of: Span, + /// Calldata for dojo_init. + pub init_calldata: Span, +} + +#[derive(Drop)] +pub struct NamespaceDef { + pub namespace: ByteArray, + pub resources: Span, +} + +#[generate_trait] +pub impl ContractDefImpl of ContractDefTrait { + fn new(namespace: @ByteArray, name: @ByteArray,) -> ContractDef { + ContractDef { + contract: ContractDescriptor::Named((namespace, name)), + writer_of: [].span(), + owner_of: [].span(), + init_calldata: [].span() + } + } + + fn new_address(address: ContractAddress) -> ContractDef { + ContractDef { + contract: ContractDescriptor::Address(address), + writer_of: [].span(), + owner_of: [].span(), + init_calldata: [].span() + } + } + + fn with_init_calldata(mut self: ContractDef, init_calldata: Span) -> ContractDef { + match self.contract { + ContractDescriptor::Address(_) => panic!( + "Cannot set init_calldata for address descriptor" + ), + ContractDescriptor::Named(_) => self.init_calldata = init_calldata, + }; + + self + } + + fn with_writer_of(mut self: ContractDef, writer_of: Span) -> ContractDef { + self.writer_of = writer_of; + self + } + + fn with_owner_of(mut self: ContractDef, owner_of: Span) -> ContractDef { + self.owner_of = owner_of; + self + } +} + +/// Deploy classhash with calldata for constructor +/// +/// # Arguments +/// +/// * `class_hash` - Class to deploy +/// * `calldata` - calldata for constructor +/// +/// # Returns +/// * address of contract deployed +pub fn deploy_contract(class_hash: felt252, calldata: Span) -> ContractAddress { + let (contract, _) = starknet::syscalls::deploy_syscall( + class_hash.try_into().unwrap(), 0, calldata, false + ) + .unwrap(); + contract +} + +/// Deploy classhash and passes in world address to constructor +/// +/// # Arguments +/// +/// * `class_hash` - Class to deploy +/// * `world` - World dispatcher to pass as world address +/// +/// # Returns +/// * address of contract deployed +pub fn deploy_with_world_address(class_hash: felt252, world: IWorldDispatcher) -> ContractAddress { + deploy_contract(class_hash, [world.contract_address.into()].span()) +} + +/// Spawns a test world registering provided resources into namespaces. +/// +/// This function only deploys the world and registers the resources, it does not initialize the +/// contracts or any permissions. +/// The first namespace is used as the default namespace when [`WorldStorage`] is returned. +/// +/// # Arguments +/// +/// * `namespaces_defs` - Definitions of namespaces to register. +/// +/// # Returns +/// +/// * World dispatcher +pub fn spawn_test_world(namespaces_defs: Span) -> WorldStorage { + let salt = core::testing::get_available_gas(); + + let (world_address, _) = deploy_syscall( + world::TEST_CLASS_HASH.try_into().unwrap(), + salt.into(), + [world::TEST_CLASS_HASH].span(), + false + ) + .unwrap(); + + let world = IWorldDispatcher { contract_address: world_address }; + + let mut first_namespace = Option::None; + + for ns in namespaces_defs { + let namespace = ns.namespace.clone(); + world.register_namespace(namespace.clone()); + + if first_namespace.is_none() { + first_namespace = Option::Some(namespace.clone()); + } + + for r in ns + .resources + .clone() { + match r { + TestResource::Event(ch) => { + world.register_event(namespace.clone(), (*ch).try_into().unwrap()); + }, + TestResource::Model(ch) => { + world.register_model(namespace.clone(), (*ch).try_into().unwrap()); + }, + TestResource::Contract(ch) => { + world.register_contract(*ch, namespace.clone(), (*ch).try_into().unwrap()); + } + } + } + }; + + WorldStorageTrait::new(world, @first_namespace.unwrap()) +} + +#[generate_trait] +pub impl WorldStorageInternalTestImpl of WorldStorageTestTrait { + fn sync_perms_and_inits(self: @WorldStorage, contracts: Span) { + // First, sync permissions as sozo is doing. + for c in contracts { + let contract_address = match c.contract { + ContractDescriptor::Address(address) => *address, + ContractDescriptor::Named(( + namespace, name + )) => { + let selector = dojo::utils::selector_from_names(*namespace, *name); + match (*self.dispatcher).resource(selector) { + dojo::world::Resource::Contract((address, _)) => address, + _ => panic!("Contract not found"), + } + }, + }; + + for w in *c.writer_of { + (*self.dispatcher).grant_writer(*w, contract_address); + }; + + for o in *c.owner_of { + (*self.dispatcher).grant_owner(*o, contract_address); + }; + }; + + // Then, calls the dojo_init for each contract that is a dojo contract. + for c in contracts { + match c.contract { + ContractDescriptor::Address(_) => {}, + ContractDescriptor::Named(( + namespace, name + )) => { + let selector = dojo::utils::selector_from_names(*namespace, *name); + (*self.dispatcher).init_contract(selector, *c.init_calldata); + } + } + }; + } +} diff --git a/crates/dojo/core/Scarb.lock b/crates/dojo/core/Scarb.lock index 5a313fab2b..4b129b0176 100644 --- a/crates/dojo/core/Scarb.lock +++ b/crates/dojo/core/Scarb.lock @@ -5,9 +5,9 @@ version = 1 name = "dojo" version = "1.0.1" dependencies = [ - "dojo_plugin", + "dojo_macros", ] [[package]] -name = "dojo_plugin" -version = "2.8.4" +name = "dojo_macros" +version = "0.1.0" diff --git a/crates/dojo/core/Scarb.toml b/crates/dojo/core/Scarb.toml index 52d1390721..0f20f20be5 100644 --- a/crates/dojo/core/Scarb.toml +++ b/crates/dojo/core/Scarb.toml @@ -7,8 +7,7 @@ version = "1.0.1" [dependencies] starknet = "=2.8.4" -dojo_plugin = { path = "../lang" } -#dojo_macros = { path = "../macros" } +dojo_macros = { path = "../macros" } [dev-dependencies] cairo_test = "=2.8.4" diff --git a/crates/dojo/lang/src/attribute_macros/contract.rs b/crates/dojo/lang/src/attribute_macros/contract.rs index 2f9c94c9b7..057f7f068b 100644 --- a/crates/dojo/lang/src/attribute_macros/contract.rs +++ b/crates/dojo/lang/src/attribute_macros/contract.rs @@ -35,32 +35,6 @@ impl DojoContract { module_ast: &ast::ItemModule, metadata: &MacroPluginMetadata<'_>, ) -> PluginResult { - let name = module_ast.name(db).text(db); - - let mut contract = DojoContract { diagnostics: vec![], systems: vec![] }; - - for (id, value) in [("name", &name.to_string())] { - if !naming::is_name_valid(value) { - return PluginResult { - code: None, - diagnostics: vec![PluginDiagnostic { - stable_ptr: module_ast.stable_ptr().0, - message: format!( - "The contract {id} '{value}' can only contain characters (a-z/A-Z), \ - digits (0-9) and underscore (_)." - ), - severity: Severity::Error, - }], - remove_original_item: false, - }; - } - } - - let mut has_event = false; - let mut has_storage = false; - let mut has_init = false; - let mut has_constructor = false; - if let MaybeModuleBody::Some(body) = module_ast.body(db) { let mut body_nodes: Vec<_> = body .iter_items_in_cfg(db, metadata.cfg_set) diff --git a/crates/dojo/macros/Cargo.toml b/crates/dojo/macros/Cargo.toml new file mode 100644 index 0000000000..6f0a0f8e33 --- /dev/null +++ b/crates/dojo/macros/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "dojo-macros" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +#cairo-lang-macro = {git = "https://github.com/software-mansion/scarb", rev="aff99810c37ceb77b61b3bd2ecee14a253a3397e"} +cairo-lang-macro = {path = "/Users/remybaranx/pro/projets/contribs/scarb/plugins/cairo-lang-macro" } +cairo-lang-defs.workspace = true +cairo-lang-parser.workspace = true +cairo-lang-plugins.workspace = true +cairo-lang-syntax.workspace = true +cairo-lang-utils.workspace = true +convert_case.workspace = true +dojo-types.workspace = true +serde.workspace = true +serde_json.workspace = true +starknet.workspace = true +starknet-crypto.workspace = true + +[dev-dependencies] +regex = "1.11.1" diff --git a/crates/dojo/macros/Scarb.lock b/crates/dojo/macros/Scarb.lock new file mode 100644 index 0000000000..2e5a11a7a4 --- /dev/null +++ b/crates/dojo/macros/Scarb.lock @@ -0,0 +1,6 @@ +# Code generated by scarb DO NOT EDIT. +version = 1 + +[[package]] +name = "dojo_macros" +version = "0.1.0" diff --git a/crates/dojo/macros/Scarb.toml b/crates/dojo/macros/Scarb.toml new file mode 100644 index 0000000000..17a88dd076 --- /dev/null +++ b/crates/dojo/macros/Scarb.toml @@ -0,0 +1,8 @@ +[package] +name = "dojo_macros" +version = "0.1.0" +description = "Dojo macros" +homepage = "https://github.com/dojoengine/dojo" +edition = "2024_07" + +[cairo-plugin] diff --git a/crates/dojo/macros/src/attributes/constants.rs b/crates/dojo/macros/src/attributes/constants.rs new file mode 100644 index 0000000000..4b9862c151 --- /dev/null +++ b/crates/dojo/macros/src/attributes/constants.rs @@ -0,0 +1,8 @@ +/// Dojo attribute names. +/// Note that, at the moment, these names must match with +/// proc macro function names. +pub const DOJO_CONTRACT_ATTR: &str = "dojo_contract"; +pub const DOJO_EVENT_ATTR: &str = "dojo_event"; +pub const DOJO_MODEL_ATTR: &str = "dojo_model"; + +pub const DOJO_ATTR_NAMES: [&str; 3] = [DOJO_CONTRACT_ATTR, DOJO_EVENT_ATTR, DOJO_MODEL_ATTR]; diff --git a/crates/dojo/macros/src/attributes/dojo_contract.rs b/crates/dojo/macros/src/attributes/dojo_contract.rs new file mode 100644 index 0000000000..d305b4e36f --- /dev/null +++ b/crates/dojo/macros/src/attributes/dojo_contract.rs @@ -0,0 +1,340 @@ +//! `dojo_contract` attribute macro. + +use cairo_lang_defs::patcher::{PatchBuilder, RewriteNode}; +use cairo_lang_macro::{attribute_macro, Diagnostic, Diagnostics, ProcMacroResult, TokenStream}; +use cairo_lang_parser::utils::SimpleParserDatabase; +use cairo_lang_syntax::node::ast::{MaybeModuleBody, OptionReturnTypeClause}; +use cairo_lang_syntax::node::db::SyntaxGroup; +use cairo_lang_syntax::node::helpers::BodyItems; +use cairo_lang_syntax::node::kind::SyntaxKind::ItemModule; +use cairo_lang_syntax::node::{ast, Terminal, TypedSyntaxNode}; +use cairo_lang_utils::unordered_hash_map::UnorderedHashMap; + +use super::constants::DOJO_CONTRACT_ATTR; +use super::struct_parser::{validate_attributes, validate_namings_diagnostics}; +use crate::diagnostic_ext::DiagnosticsExt; + +const CONSTRUCTOR_FN: &str = "constructor"; +pub const DOJO_INIT_FN: &str = "dojo_init"; + +const CONTRACT_PATCH: &str = include_str!("./patches/contract.patch.cairo"); +const DEFAULT_INIT_PATCH: &str = include_str!("./patches/default_init.patch.cairo"); + +#[attribute_macro("dojo::contract")] +pub fn dojo_contract(_args: TokenStream, token_stream: TokenStream) -> ProcMacroResult { + handle_module_attribute_macro(token_stream) +} + +pub fn handle_module_attribute_macro(token_stream: TokenStream) -> ProcMacroResult { + let db = SimpleParserDatabase::default(); + let (root_node, _diagnostics) = db.parse_virtual_with_diagnostics(token_stream); + + for n in root_node.descendants(&db) { + // Process only the first module expected to be the contract. + if n.kind(&db) == ItemModule { + let module_ast = ast::ItemModule::from_syntax_node(&db, n); + return from_module(&db, &module_ast); + } + } + + ProcMacroResult::new(TokenStream::empty()) +} + +pub fn from_module(db: &dyn SyntaxGroup, module_ast: &ast::ItemModule) -> ProcMacroResult { + let name = module_ast.name(db).text(db); + + let mut diagnostics = vec![]; + + diagnostics.extend(validate_attributes(db, &module_ast.attributes(db), DOJO_CONTRACT_ATTR)); + + diagnostics.extend(validate_namings_diagnostics(&[("contract name", &name)])); + + let mut has_event = false; + let mut has_storage = false; + let mut has_init = false; + let mut has_constructor = false; + + if let MaybeModuleBody::Some(body) = module_ast.body(db) { + // TODO: Use `.iter_items_in_cfg(db, metadata.cfg_set)` when possible + // to ensure we don't loop on items that are not in the current cfg set. + let mut body_nodes: Vec<_> = body + .items_vec(db) + .iter() + .flat_map(|el| { + if let ast::ModuleItem::Enum(ref enum_ast) = el { + if enum_ast.name(db).text(db).to_string() == "Event" { + has_event = true; + + return merge_event(db, enum_ast.clone()); + } + } else if let ast::ModuleItem::Struct(ref struct_ast) = el { + if struct_ast.name(db).text(db).to_string() == "Storage" { + has_storage = true; + return merge_storage(db, struct_ast.clone()); + } + } else if let ast::ModuleItem::FreeFunction(ref fn_ast) = el { + let fn_decl = fn_ast.declaration(db); + let fn_name = fn_decl.name(db).text(db); + + if fn_name == CONSTRUCTOR_FN { + has_constructor = true; + return handle_constructor_fn(db, fn_ast); + } + + if fn_name == DOJO_INIT_FN { + has_init = true; + return handle_init_fn(db, fn_ast, &mut diagnostics); + } + } + + vec![RewriteNode::Copied(el.as_syntax_node())] + }) + .collect(); + + if !has_constructor { + let node = RewriteNode::Text( + " + #[constructor] + fn constructor(ref self: ContractState) { + self.world_provider.initializer(); + } + " + .to_string(), + ); + + body_nodes.append(&mut vec![node]); + } + + if !has_init { + let node = RewriteNode::interpolate_patched( + DEFAULT_INIT_PATCH, + &UnorderedHashMap::from([( + "init_name".to_string(), + RewriteNode::Text(DOJO_INIT_FN.to_string()), + )]), + ); + body_nodes.append(&mut vec![node]); + } + + if !has_event { + body_nodes.append(&mut create_event()) + } + + if !has_storage { + body_nodes.append(&mut create_storage()) + } + + let mut builder = PatchBuilder::new(db, module_ast); + builder.add_modified(RewriteNode::Mapped { + node: Box::new(RewriteNode::interpolate_patched( + CONTRACT_PATCH, + &UnorderedHashMap::from([ + ("name".to_string(), RewriteNode::Text(name.to_string())), + ("body".to_string(), RewriteNode::new_modified(body_nodes)), + ]), + )), + origin: module_ast.as_syntax_node().span_without_trivia(db), + }); + + let (code, _) = builder.build(); + + crate::debug_expand(&format!("CONTRACT PATCH: {name}"), &code); + + return ProcMacroResult::new(TokenStream::new(code)) + .with_diagnostics(Diagnostics::new(diagnostics)); + } + + ProcMacroResult::new(TokenStream::empty()) +} +/// If a constructor is provided, we should keep the user statements. +/// We only inject the world provider initializer. +fn handle_constructor_fn(db: &dyn SyntaxGroup, fn_ast: &ast::FunctionWithBody) -> Vec { + let fn_decl = fn_ast.declaration(db); + + let params_str = params_to_str(db, fn_decl.signature(db).parameters(db)); + + let declaration_node = RewriteNode::Mapped { + node: Box::new(RewriteNode::Text(format!( + " + #[constructor] + fn constructor({}) {{ + self.world_provider.initializer(); + ", + params_str + ))), + origin: fn_ast.declaration(db).as_syntax_node().span_without_trivia(db), + }; + + let func_nodes = fn_ast + .body(db) + .statements(db) + .elements(db) + .iter() + .map(|e| RewriteNode::Mapped { + node: Box::new(RewriteNode::from(e.as_syntax_node())), + origin: e.as_syntax_node().span_without_trivia(db), + }) + .collect::>(); + + let mut nodes = vec![declaration_node]; + + nodes.extend(func_nodes); + + // Close the constructor with users statements included. + nodes.push(RewriteNode::Text("}\n".to_string())); + + nodes +} + +fn handle_init_fn( + db: &dyn SyntaxGroup, + fn_ast: &ast::FunctionWithBody, + diagnostics: &mut Vec, +) -> Vec { + let fn_decl = fn_ast.declaration(db); + + if let OptionReturnTypeClause::ReturnTypeClause(_) = fn_decl.signature(db).ret_ty(db) { + diagnostics.push_error(format!("The {} function cannot have a return type.", DOJO_INIT_FN)); + } + + let params: Vec = fn_decl + .signature(db) + .parameters(db) + .elements(db) + .iter() + .map(|p| p.as_syntax_node().get_text(db)) + .collect::>(); + + let params_str = params.join(", "); + + // Since the dojo init is meant to be called by the world, we don't need an + // interface to be generated (which adds a considerable amount of code). + let impl_node = RewriteNode::Text( + " + #[abi(per_item)] + #[generate_trait] + pub impl IDojoInitImpl of IDojoInit { + #[external(v0)] + " + .to_string(), + ); + + let declaration_node = RewriteNode::Mapped { + node: Box::new(RewriteNode::Text(format!("fn {}({}) {{", DOJO_INIT_FN, params_str))), + origin: fn_ast.declaration(db).as_syntax_node().span_without_trivia(db), + }; + + // Asserts the caller is the world, and close the init function. + let assert_world_caller_node = RewriteNode::Text( + "if starknet::get_caller_address() != \ + self.world_provider.world_dispatcher().contract_address { \ + core::panics::panic_with_byte_array(@format!(\"Only the world can init contract `{}`, \ + but caller is `{:?}`\", self.dojo_name(), starknet::get_caller_address())); }" + .to_string(), + ); + + let func_nodes = fn_ast + .body(db) + .statements(db) + .elements(db) + .iter() + .map(|e| RewriteNode::Mapped { + node: Box::new(RewriteNode::from(e.as_syntax_node())), + origin: e.as_syntax_node().span_without_trivia(db), + }) + .collect::>(); + + let mut nodes = vec![impl_node, declaration_node, assert_world_caller_node]; + nodes.extend(func_nodes); + // Close the init function + close the impl block. + nodes.push(RewriteNode::Text("}\n}".to_string())); + + nodes +} + +pub fn merge_event(db: &dyn SyntaxGroup, enum_ast: ast::ItemEnum) -> Vec { + let mut rewrite_nodes = vec![]; + + let elements = enum_ast.variants(db).elements(db); + + let variants = elements.iter().map(|e| e.as_syntax_node().get_text(db)).collect::>(); + let variants = variants.join(",\n"); + + rewrite_nodes.push(RewriteNode::interpolate_patched( + " + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + UpgradeableEvent: upgradeable_cpt::Event, + WorldProviderEvent: world_provider_cpt::Event, + $variants$ + } + ", + &UnorderedHashMap::from([("variants".to_string(), RewriteNode::Text(variants))]), + )); + rewrite_nodes +} + +pub fn create_event() -> Vec { + vec![RewriteNode::Text( + " + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + UpgradeableEvent: upgradeable_cpt::Event, + WorldProviderEvent: world_provider_cpt::Event, + } + " + .to_string(), + )] +} + +pub fn merge_storage(db: &dyn SyntaxGroup, struct_ast: ast::ItemStruct) -> Vec { + let mut rewrite_nodes = vec![]; + + let elements = struct_ast.members(db).elements(db); + + let members = elements.iter().map(|e| e.as_syntax_node().get_text(db)).collect::>(); + let members = members.join(",\n"); + + rewrite_nodes.push(RewriteNode::interpolate_patched( + " + #[storage] + struct Storage { + #[substorage(v0)] + upgradeable: upgradeable_cpt::Storage, + #[substorage(v0)] + world_provider: world_provider_cpt::Storage, + $members$ + } + ", + &UnorderedHashMap::from([("members".to_string(), RewriteNode::Text(members))]), + )); + rewrite_nodes +} + +pub fn create_storage() -> Vec { + vec![RewriteNode::Text( + " + #[storage] + struct Storage { + #[substorage(v0)] + upgradeable: upgradeable_cpt::Storage, + #[substorage(v0)] + world_provider: world_provider_cpt::Storage, + } + " + .to_string(), + )] +} + +/// Converts parameter list to it's string representation. +pub fn params_to_str(db: &dyn SyntaxGroup, param_list: ast::ParamList) -> String { + let params = param_list + .elements(db) + .iter() + .map(|param| param.as_syntax_node().get_text(db)) + .collect::>(); + + params.join(", ") +} diff --git a/crates/dojo/macros/src/attributes/dojo_event.rs b/crates/dojo/macros/src/attributes/dojo_event.rs new file mode 100644 index 0000000000..d3c16d20a8 --- /dev/null +++ b/crates/dojo/macros/src/attributes/dojo_event.rs @@ -0,0 +1,124 @@ +//! `dojo_event` attribute macro. + +use cairo_lang_defs::patcher::{PatchBuilder, RewriteNode}; +use cairo_lang_macro::{attribute_macro, Diagnostics, ProcMacroResult, TokenStream}; +use cairo_lang_syntax::node::db::SyntaxGroup; +use cairo_lang_syntax::node::helpers::QueryAttrs; +use cairo_lang_syntax::node::{ast, TypedSyntaxNode}; +use cairo_lang_utils::unordered_hash_map::UnorderedHashMap; + +use super::constants::DOJO_EVENT_ATTR; +use super::struct_parser::{ + compute_unique_hash, handle_struct_attribute_macro, parse_members, serialize_keys_and_values, + validate_attributes, validate_namings_diagnostics, +}; +use crate::attributes::struct_parser::remove_derives; +use crate::derives::{extract_derive_attr_names, DOJO_INTROSPECT_DERIVE, DOJO_PACKED_DERIVE}; +use crate::diagnostic_ext::DiagnosticsExt; + +const EVENT_PATCH: &str = include_str!("./patches/event.patch.cairo"); + +#[attribute_macro("dojo::event")] +pub fn dojo_event(_args: TokenStream, token_stream: TokenStream) -> ProcMacroResult { + handle_event_attribute_macro(token_stream) +} + +// inner function to be called in tests as `dojo_event()` is automatically renamed +// by the `attribute_macro` processing +pub fn handle_event_attribute_macro(token_stream: TokenStream) -> ProcMacroResult { + handle_struct_attribute_macro(token_stream, from_struct) +} + +pub fn from_struct(db: &dyn SyntaxGroup, struct_ast: &ast::ItemStruct) -> ProcMacroResult { + let mut diagnostics = vec![]; + + let event_name = struct_ast.name(db).as_syntax_node().get_text(db).trim().to_string(); + + diagnostics.extend(validate_attributes(db, &struct_ast.attributes(db), DOJO_EVENT_ATTR)); + + diagnostics.extend(validate_namings_diagnostics(&[("event name", &event_name)])); + + let members = parse_members(db, &struct_ast.members(db).elements(db), &mut diagnostics); + + let mut serialized_keys: Vec = vec![]; + let mut serialized_values: Vec = vec![]; + + serialize_keys_and_values(&members, &mut serialized_keys, &mut serialized_values); + + if serialized_keys.is_empty() { + diagnostics.push_error("Event must define at least one #[key] attribute".to_string()); + } + + if serialized_values.is_empty() { + diagnostics + .push_error("Event must define at least one member that is not a key".to_string()); + } + + let members_values = members + .iter() + .filter_map(|m| { + if m.key { + None + } else { + Some(RewriteNode::Text(format!("pub {}: {},\n", m.name, m.ty))) + } + }) + .collect::>(); + + let member_names = members + .iter() + .map(|member| RewriteNode::Text(format!("{},\n", member.name.clone()))) + .collect::>(); + + let derive_attr_names = extract_derive_attr_names( + db, + &mut diagnostics, + struct_ast.attributes(db).query_attr(db, "derive"), + ); + + if derive_attr_names.contains(&DOJO_PACKED_DERIVE.to_string()) { + diagnostics.push_error(format!("Deriving {DOJO_PACKED_DERIVE} on event is not allowed.")); + } + + let has_drop = derive_attr_names.contains(&"Drop".to_string()); + let has_serde = derive_attr_names.contains(&"Serde".to_string()); + + if !has_drop || !has_serde { + diagnostics.push_error("Event must derive from Drop and Serde.".to_string()); + } + + // Ensures events always derive Introspect if not already derived. + let derive_node = RewriteNode::Text(format!("#[derive({})]", DOJO_INTROSPECT_DERIVE)); + + // Must remove the derives from the original struct since they would create duplicates + // with the derives of other plugins. + let original_struct = remove_derives(db, struct_ast); + + let unique_hash = + compute_unique_hash(db, &event_name, false, &struct_ast.members(db).elements(db)) + .to_string(); + + let dojo_node = RewriteNode::interpolate_patched( + EVENT_PATCH, + &UnorderedHashMap::from([ + ("derive_node".to_string(), derive_node), + ("original_struct".to_string(), original_struct), + ("type_name".to_string(), RewriteNode::Text(event_name.clone())), + ("member_names".to_string(), RewriteNode::new_modified(member_names)), + ("serialized_keys".to_string(), RewriteNode::new_modified(serialized_keys)), + ("serialized_values".to_string(), RewriteNode::new_modified(serialized_values)), + ("unique_hash".to_string(), RewriteNode::Text(unique_hash)), + ("members_values".to_string(), RewriteNode::new_modified(members_values)), + ]), + ); + + let mut builder = PatchBuilder::new(db, struct_ast); + builder.add_modified(dojo_node); + + let (code, _) = builder.build(); + + crate::debug_expand(&format!("EVENT PATCH: {event_name}"), &code); + + return ProcMacroResult::new(TokenStream::new(code)) + .with_diagnostics(Diagnostics::new(diagnostics)); +} diff --git a/crates/dojo/macros/src/attributes/dojo_model.rs b/crates/dojo/macros/src/attributes/dojo_model.rs new file mode 100644 index 0000000000..06f256f258 --- /dev/null +++ b/crates/dojo/macros/src/attributes/dojo_model.rs @@ -0,0 +1,195 @@ +//! `dojo_model` attribute macro. + +use cairo_lang_defs::patcher::{PatchBuilder, RewriteNode}; +use cairo_lang_macro::{attribute_macro, Diagnostics, ProcMacroResult, TokenStream}; +use cairo_lang_syntax::node::db::SyntaxGroup; +use cairo_lang_syntax::node::helpers::QueryAttrs; +use cairo_lang_syntax::node::{ast, TypedSyntaxNode}; +use cairo_lang_utils::unordered_hash_map::UnorderedHashMap; +use starknet::core::utils::get_selector_from_name; + +use super::constants::DOJO_MODEL_ATTR; +use super::struct_parser::{ + compute_unique_hash, handle_struct_attribute_macro, parse_members, serialize_member_ty, + validate_attributes, validate_namings_diagnostics, Member, +}; +use crate::attributes::struct_parser::remove_derives; +use crate::derives::{extract_derive_attr_names, DOJO_INTROSPECT_DERIVE, DOJO_PACKED_DERIVE}; +use crate::diagnostic_ext::DiagnosticsExt; + +const MODEL_CODE_PATCH: &str = include_str!("./patches/model.patch.cairo"); +const MODEL_FIELD_CODE_PATCH: &str = include_str!("./patches/model_field_store.patch.cairo"); + +#[attribute_macro("dojo::model")] +pub fn dojo_model(_args: TokenStream, token_stream: TokenStream) -> ProcMacroResult { + handle_model_attribute_macro(token_stream) +} + +// inner function to be called in tests as `dojo_model()` is automatically renamed +// by the `attribute_macro` processing +pub fn handle_model_attribute_macro(token_stream: TokenStream) -> ProcMacroResult { + handle_struct_attribute_macro(token_stream, from_struct) +} + +pub fn from_struct(db: &dyn SyntaxGroup, struct_ast: &ast::ItemStruct) -> ProcMacroResult { + let mut diagnostics = vec![]; + + let model_type = struct_ast.name(db).as_syntax_node().get_text(db).trim().to_string(); + + diagnostics.extend(validate_attributes(db, &struct_ast.attributes(db), DOJO_MODEL_ATTR)); + diagnostics.extend(validate_namings_diagnostics(&[("model name", &model_type)])); + + let mut values: Vec = vec![]; + let mut keys: Vec = vec![]; + let mut members_values: Vec = vec![]; + let mut key_types: Vec = vec![]; + let mut key_attrs: Vec = vec![]; + + let mut serialized_keys: Vec = vec![]; + let mut serialized_values: Vec = vec![]; + let mut field_accessors: Vec = vec![]; + + let members = parse_members(db, &struct_ast.members(db).elements(db), &mut diagnostics); + + members.iter().for_each(|member| { + if member.key { + keys.push(member.clone()); + key_types.push(member.ty.clone()); + key_attrs.push(format!("*self.{}", member.name.clone())); + serialized_keys.push(serialize_member_ty(member, true)); + } else { + values.push(member.clone()); + serialized_values.push(serialize_member_ty(member, true)); + members_values + .push(RewriteNode::Text(format!("pub {}: {},\n", member.name, member.ty))); + field_accessors.push(generate_field_accessors(model_type.clone(), member)); + } + }); + + if keys.is_empty() { + diagnostics.push_error("Model must define at least one #[key] attribute".to_string()); + } + + if values.is_empty() { + diagnostics + .push_error("Model must define at least one member that is not a key".to_string()); + } + + if !diagnostics.is_empty() { + return ProcMacroResult::new(TokenStream::empty()) + .with_diagnostics(Diagnostics::new(diagnostics)); + } + + let (keys_to_tuple, key_type) = if keys.len() > 1 { + (format!("({})", key_attrs.join(", ")), format!("({})", key_types.join(", "))) + } else { + (key_attrs.first().unwrap().to_string(), key_types.first().unwrap().to_string()) + }; + + let derive_attr_names = extract_derive_attr_names( + db, + &mut diagnostics, + struct_ast.attributes(db).query_attr(db, "derive"), + ); + + let has_introspect = derive_attr_names.contains(&DOJO_INTROSPECT_DERIVE.to_string()); + let has_introspect_packed = derive_attr_names.contains(&DOJO_PACKED_DERIVE.to_string()); + let has_drop = derive_attr_names.contains(&"Drop".to_string()); + let has_serde = derive_attr_names.contains(&"Serde".to_string()); + + if has_introspect && has_introspect_packed { + diagnostics.push_error( + "Model cannot derive from both Introspect and IntrospectPacked.".to_string(), + ); + } + + #[allow(clippy::nonminimal_bool)] + if !has_drop || !has_serde { + diagnostics.push_error("Model must derive from Drop and Serde.".to_string()); + } + + let derive_node = if has_introspect_packed { + RewriteNode::Text(format!("#[derive({})]", DOJO_PACKED_DERIVE)) + } else { + RewriteNode::Text(format!("#[derive({})]", DOJO_INTROSPECT_DERIVE)) + }; + + // Must remove the derives from the original struct since they would create duplicates + // with the derives of other plugins. + let original_struct = remove_derives(db, struct_ast); + + // Reuse the same derive attributes for ModelValue (except Introspect/IntrospectPacked). + let model_value_derive_attr_names = derive_attr_names + .iter() + .map(|d| d.as_str()) + .filter(|&d| d != DOJO_INTROSPECT_DERIVE && d != DOJO_PACKED_DERIVE) + .collect::>() + .join(", "); + + let unique_hash = compute_unique_hash( + db, + &model_type, + has_introspect_packed, + &struct_ast.members(db).elements(db), + ) + .to_string(); + + let dojo_node = RewriteNode::interpolate_patched( + MODEL_CODE_PATCH, + &UnorderedHashMap::from([ + ("derive_node".to_string(), derive_node), + ("original_struct".to_string(), original_struct), + ("model_type".to_string(), RewriteNode::Text(model_type.clone())), + ("serialized_keys".to_string(), RewriteNode::new_modified(serialized_keys)), + ("serialized_values".to_string(), RewriteNode::new_modified(serialized_values)), + ("keys_to_tuple".to_string(), RewriteNode::Text(keys_to_tuple)), + ("key_type".to_string(), RewriteNode::Text(key_type)), + ("members_values".to_string(), RewriteNode::new_modified(members_values)), + ("field_accessors".to_string(), RewriteNode::new_modified(field_accessors)), + ( + "model_value_derive_attr_names".to_string(), + RewriteNode::Text(model_value_derive_attr_names), + ), + ("unique_hash".to_string(), RewriteNode::Text(unique_hash)), + ]), + ); + + let mut builder = PatchBuilder::new(db, struct_ast); + builder.add_modified(dojo_node); + + let (code, _) = builder.build(); + + crate::debug_expand(&format!("MODEL PATCH: {model_type}"), &code); + + return ProcMacroResult::new(TokenStream::new(code)) + .with_diagnostics(Diagnostics::new(diagnostics)); +} + +/// Generates field accessors (`get_[field_name]` and `set_[field_name]`) for every +/// fields of a model. +/// +/// # Arguments +/// +/// * `model_name` - the model name. +/// * `param_keys` - coma separated model keys with the format `KEY_NAME: KEY_TYPE`. +/// * `serialized_param_keys` - code to serialize model keys in a `serialized` felt252 array. +/// * `member` - information about the field for which to generate accessors. +/// +/// # Returns +/// A [`RewriteNode`] containing accessors code. +fn generate_field_accessors(model_type: String, member: &Member) -> RewriteNode { + RewriteNode::interpolate_patched( + MODEL_FIELD_CODE_PATCH, + &UnorderedHashMap::from([ + ("model_type".to_string(), RewriteNode::Text(model_type)), + ( + "field_selector".to_string(), + RewriteNode::Text( + get_selector_from_name(&member.name).expect("invalid member name").to_string(), + ), + ), + ("field_name".to_string(), RewriteNode::Text(member.name.clone())), + ("field_type".to_string(), RewriteNode::Text(member.ty.clone())), + ]), + ) +} diff --git a/crates/dojo/macros/src/attributes/mod.rs b/crates/dojo/macros/src/attributes/mod.rs new file mode 100644 index 0000000000..a243e83ac9 --- /dev/null +++ b/crates/dojo/macros/src/attributes/mod.rs @@ -0,0 +1,5 @@ +pub mod constants; +pub mod dojo_contract; +pub mod dojo_event; +pub mod dojo_model; +pub mod struct_parser; diff --git a/crates/dojo/macros/src/attributes/patches/contract.patch.cairo b/crates/dojo/macros/src/attributes/patches/contract.patch.cairo new file mode 100644 index 0000000000..46ee7353b1 --- /dev/null +++ b/crates/dojo/macros/src/attributes/patches/contract.patch.cairo @@ -0,0 +1,35 @@ +#[starknet::contract] +pub mod $name$ { + use dojo::contract::components::world_provider::{world_provider_cpt, world_provider_cpt::InternalTrait as WorldProviderInternal, IWorldProvider}; + use dojo::contract::components::upgradeable::upgradeable_cpt; + use dojo::contract::IContract; + use dojo::meta::IDeployedResource; + + component!(path: world_provider_cpt, storage: world_provider, event: WorldProviderEvent); + component!(path: upgradeable_cpt, storage: upgradeable, event: UpgradeableEvent); + + #[abi(embed_v0)] + impl WorldProviderImpl = world_provider_cpt::WorldProviderImpl; + + #[abi(embed_v0)] + impl UpgradeableImpl = upgradeable_cpt::UpgradeableImpl; + + #[abi(embed_v0)] + pub impl $name$__ContractImpl of IContract {} + + #[abi(embed_v0)] + pub impl $name$__DeployedContractImpl of IDeployedResource { + fn dojo_name(self: @ContractState) -> ByteArray { + "$name$" + } + } + + #[generate_trait] + impl $name$InternalImpl of $name$InternalTrait { + fn world(self: @ContractState, namespace: @ByteArray) -> dojo::world::storage::WorldStorage { + dojo::world::WorldStorageTrait::new(self.world_provider.world_dispatcher(), namespace) + } + } + + $body$ +} diff --git a/crates/dojo/macros/src/attributes/patches/default_init.patch.cairo b/crates/dojo/macros/src/attributes/patches/default_init.patch.cairo new file mode 100644 index 0000000000..435bad567e --- /dev/null +++ b/crates/dojo/macros/src/attributes/patches/default_init.patch.cairo @@ -0,0 +1,14 @@ +#[abi(per_item)] +#[generate_trait] +pub impl IDojoInitImpl of IDojoInit { + #[external(v0)] + fn $init_name$(self: @ContractState) { + if starknet::get_caller_address() != self.world_provider.world_dispatcher().contract_address { + core::panics::panic_with_byte_array( + @format!("Only the world can init contract `{}`, but caller is `{:?}`", + self.dojo_name(), + starknet::get_caller_address(), + )); + } + } +} diff --git a/crates/dojo/macros/src/attributes/patches/event.patch.cairo b/crates/dojo/macros/src/attributes/patches/event.patch.cairo new file mode 100644 index 0000000000..a6275ab39c --- /dev/null +++ b/crates/dojo/macros/src/attributes/patches/event.patch.cairo @@ -0,0 +1,76 @@ +$derive_node$ +$original_struct$ + +// EventValue on it's own does nothing since events are always emitted and +// never read from the storage. However, it's required by the ABI to +// ensure that the event definition contains both keys and values easily distinguishable. +// Only derives strictly required traits. +#[derive(Drop, Serde)] +pub struct $type_name$Value { + $members_values$ +} + +pub impl $type_name$Definition of dojo::event::EventDefinition<$type_name$>{ + #[inline(always)] + fn name() -> ByteArray { + "$type_name$" + } +} + +pub impl $type_name$ModelParser of dojo::model::model::ModelParser<$type_name$>{ + fn serialize_keys(self: @$type_name$) -> Span { + let mut serialized = core::array::ArrayTrait::new(); + $serialized_keys$ + core::array::ArrayTrait::span(@serialized) + } + fn serialize_values(self: @$type_name$) -> Span { + let mut serialized = core::array::ArrayTrait::new(); + $serialized_values$ + core::array::ArrayTrait::span(@serialized) + } +} + +pub impl $type_name$EventImpl = dojo::event::event::EventImpl<$type_name$>; + +#[starknet::contract] +pub mod e_$type_name$ { + use super::$type_name$; + use super::$type_name$Value; + + #[storage] + struct Storage {} + + #[abi(embed_v0)] + impl $type_name$__DeployedEventImpl = dojo::event::component::IDeployedEventImpl; + + #[abi(embed_v0)] + impl $type_name$__StoredEventImpl = dojo::event::component::IStoredEventImpl; + + #[abi(embed_v0)] + impl $type_name$__EventImpl = dojo::event::component::IEventImpl; + + #[abi(per_item)] + #[generate_trait] + impl $type_name$Impl of I$type_name${ + // Ensures the ABI contains the Event struct, since it's never used + // by systems directly. + #[external(v0)] + fn ensure_abi(self: @ContractState, event: $type_name$) { + let _event = event; + } + + // Outputs EventValue to allow a simple diff from the ABI compared to the + // event to retrieved the keys of an event. + #[external(v0)] + fn ensure_values(self: @ContractState, value: $type_name$Value) { + let _value = value; + } + + // Ensures the generated contract has a unique classhash, using + // a hardcoded hash computed on event and member names. + #[external(v0)] + fn ensure_unique(self: @ContractState) { + let _hash = $unique_hash$; + } + } +} diff --git a/crates/dojo/macros/src/attributes/patches/model.patch.cairo b/crates/dojo/macros/src/attributes/patches/model.patch.cairo new file mode 100644 index 0000000000..77002bfaea --- /dev/null +++ b/crates/dojo/macros/src/attributes/patches/model.patch.cairo @@ -0,0 +1,120 @@ +$derive_node$ +$original_struct$ + +#[derive($model_value_derive_attr_names$)] +pub struct $model_type$Value { + $members_values$ +} + +type $model_type$KeyType = $key_type$; + +pub impl $model_type$KeyParser of dojo::model::model::KeyParser<$model_type$, $model_type$KeyType>{ + #[inline(always)] + fn parse_key(self: @$model_type$) -> $model_type$KeyType { + $keys_to_tuple$ + } +} + +impl $model_type$ModelValueKey of dojo::model::model_value::ModelValueKey<$model_type$Value, $model_type$KeyType> { +} + +// Impl to get the static definition of a model +pub mod m_$model_type$_definition { + use super::$model_type$; + pub impl $model_type$DefinitionImpl of dojo::model::ModelDefinition{ + #[inline(always)] + fn name() -> ByteArray { + "$model_type$" + } + + #[inline(always)] + fn layout() -> dojo::meta::Layout { + dojo::meta::Introspect::<$model_type$>::layout() + } + + #[inline(always)] + fn schema() -> dojo::meta::introspect::Struct { + if let dojo::meta::introspect::Ty::Struct(s) = dojo::meta::Introspect::<$model_type$>::ty() { + s + } + else { + panic!("Model `$model_type$`: invalid schema.") + } + } + + #[inline(always)] + fn size() -> Option { + dojo::meta::Introspect::<$model_type$>::size() + } + } +} + +pub impl $model_type$Definition = m_$model_type$_definition::$model_type$DefinitionImpl<$model_type$>; +pub impl $model_type$ModelValueDefinition = m_$model_type$_definition::$model_type$DefinitionImpl<$model_type$Value>; + +pub impl $model_type$ModelParser of dojo::model::model::ModelParser<$model_type$>{ + fn serialize_keys(self: @$model_type$) -> Span { + let mut serialized = core::array::ArrayTrait::new(); + $serialized_keys$ + core::array::ArrayTrait::span(@serialized) + } + fn serialize_values(self: @$model_type$) -> Span { + let mut serialized = core::array::ArrayTrait::new(); + $serialized_values$ + core::array::ArrayTrait::span(@serialized) + } +} + +pub impl $model_type$ModelValueParser of dojo::model::model_value::ModelValueParser<$model_type$Value>{ + fn serialize_values(self: @$model_type$Value) -> Span { + let mut serialized = core::array::ArrayTrait::new(); + $serialized_values$ + core::array::ArrayTrait::span(@serialized) + } +} + +pub impl $model_type$ModelImpl = dojo::model::model::ModelImpl<$model_type$>; +pub impl $model_type$ModelValueImpl = dojo::model::model_value::ModelValueImpl<$model_type$Value>; + +#[starknet::contract] +pub mod m_$model_type$ { + use super::$model_type$; + use super::$model_type$Value; + + #[storage] + struct Storage {} + + #[abi(embed_v0)] + impl $model_type$__DojoDeployedModelImpl = dojo::model::component::IDeployedModelImpl; + + #[abi(embed_v0)] + impl $model_type$__DojoStoredModelImpl = dojo::model::component::IStoredModelImpl; + + #[abi(embed_v0)] + impl $model_type$__DojoModelImpl = dojo::model::component::IModelImpl; + + #[abi(per_item)] + #[generate_trait] + impl $model_type$Impl of I$model_type${ + // Ensures the ABI contains the Model struct, even if never used + // into as a system input. + #[external(v0)] + fn ensure_abi(self: @ContractState, model: $model_type$) { + let _model = model; + } + + // Outputs ModelValue to allow a simple diff from the ABI compared to the + // model to retrieved the keys of a model. + #[external(v0)] + fn ensure_values(self: @ContractState, value: $model_type$Value) { + let _value = value; + } + + // Ensures the generated contract has a unique classhash, using + // a hardcoded hash computed on model and member names. + #[external(v0)] + fn ensure_unique(self: @ContractState) { + let _hash = $unique_hash$; + } + } +} diff --git a/crates/dojo/macros/src/attributes/patches/model_field_store.patch.cairo b/crates/dojo/macros/src/attributes/patches/model_field_store.patch.cairo new file mode 100644 index 0000000000..8fa466617f --- /dev/null +++ b/crates/dojo/macros/src/attributes/patches/model_field_store.patch.cairo @@ -0,0 +1,15 @@ + fn get_$field_name$(self: @S, key: $model_type$KeyType) -> $field_type$ { + $model_type$Store::get_member(self, key, $field_selector$) + } + + fn get_$field_name$_from_id(self: @S, entity_id: felt252) -> $field_type$ { + $model_type$ModelValueStore::get_member_from_id(self, entity_id, $field_selector$) + } + + fn update_$field_name$(ref self: S, key: $model_type$KeyType, value: $field_type$) { + $model_type$Store::update_member(ref self, key, $field_selector$, value); + } + + fn update_$field_name$_from_id(ref self: S, entity_id: felt252, value: $field_type$) { + $model_type$ModelValueStore::update_member_from_id(ref self, entity_id, $field_selector$, value); + } diff --git a/crates/dojo/macros/src/attributes/struct_parser.rs b/crates/dojo/macros/src/attributes/struct_parser.rs new file mode 100644 index 0000000000..dcb25abe47 --- /dev/null +++ b/crates/dojo/macros/src/attributes/struct_parser.rs @@ -0,0 +1,214 @@ +use cairo_lang_defs::patcher::RewriteNode; +use cairo_lang_macro::{Diagnostic, ProcMacroResult, Severity, TokenStream}; +use cairo_lang_parser::utils::SimpleParserDatabase; +use cairo_lang_syntax::node::ast::{self, AttributeList, Member as MemberAst}; +use cairo_lang_syntax::node::db::SyntaxGroup; +use cairo_lang_syntax::node::helpers::QueryAttrs; +use cairo_lang_syntax::node::kind::SyntaxKind::ItemStruct; +use cairo_lang_syntax::node::{Terminal, TypedSyntaxNode}; +use dojo_types::naming; +use dojo_types::naming::compute_bytearray_hash; +use serde::{Deserialize, Serialize}; +use starknet_crypto::{poseidon_hash_many, Felt}; + +use super::constants::DOJO_ATTR_NAMES; +use crate::diagnostic_ext::DiagnosticsExt; + +/// Represents a member of a struct. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Member { + // Name of the member. + pub name: String, + // Type of the member. + pub ty: String, + // Whether the member is a key. + pub key: bool, +} + +pub fn parse_members( + db: &dyn SyntaxGroup, + members: &[MemberAst], + diagnostics: &mut Vec, +) -> Vec { + members + .iter() + .filter_map(|member_ast| { + let member = Member { + name: member_ast.name(db).text(db).to_string(), + ty: member_ast + .type_clause(db) + .ty(db) + .as_syntax_node() + .get_text(db) + .trim() + .to_string(), + key: member_ast.has_attr(db, "key"), + }; + + // validate key member + if member.key && member.ty == "u256" { + diagnostics.push(Diagnostic { + message: "Key is only supported for core types that are 1 felt long once \ + serialized. `u256` is a struct of 2 u128, hence not supported." + .into(), + severity: Severity::Error, + }); + None + } else { + Some(member) + } + }) + .collect::>() +} + +pub fn serialize_keys_and_values( + members: &[Member], + serialized_keys: &mut Vec, + serialized_values: &mut Vec, +) { + members.iter().for_each(|member| { + if member.key { + serialized_keys.push(serialize_member_ty(member, true)); + } else { + serialized_values.push(serialize_member_ty(member, true)); + } + }); +} + +/// Creates a [`RewriteNode`] for the member type serialization. +/// +/// # Arguments +/// +/// * member: The member to serialize. +pub fn serialize_member_ty(member: &Member, with_self: bool) -> RewriteNode { + RewriteNode::Text(format!( + "core::serde::Serde::serialize({}{}, ref serialized);\n", + if with_self { "self." } else { "@" }, + member.name + )) +} + +pub fn deserialize_member_ty(member: &Member, input_name: &str) -> RewriteNode { + RewriteNode::Text(format!( + "let {} = core::serde::Serde::<{}>::deserialize(ref {input_name})?;\n", + member.name, member.ty + )) +} + +/// Validates the namings of the attributes. +/// +/// # Arguments +/// +/// * namings: A list of tuples containing the id and value of the attribute. +/// +/// # Returns +/// +/// A vector of diagnostics. +pub fn validate_namings_diagnostics(namings: &[(&str, &str)]) -> Vec { + let mut diagnostics = vec![]; + + for (id, value) in namings { + if !naming::is_name_valid(value) { + diagnostics.push_error(format!( + "The {id} '{value}' can only contain characters (a-z/A-Z), digits (0-9) and \ + underscore (_)." + )); + } + } + + diagnostics +} + +/// Removes the derives from the original struct. +pub fn remove_derives(db: &dyn SyntaxGroup, struct_ast: &ast::ItemStruct) -> RewriteNode { + let mut out_lines = vec![]; + + let struct_str = struct_ast.as_syntax_node().get_text_without_trivia(db).to_string(); + + for l in struct_str.lines() { + if !l.starts_with("#[derive") { + out_lines.push(l); + } + } + + RewriteNode::Text(out_lines.join("\n")) +} + +/// Validates the attributes of a Dojo attribute. +/// +/// Parameters: +/// * db: The semantic database. +/// * module_ast: The AST of the contract module. +/// +/// Returns: +/// * A vector of diagnostics. +pub fn validate_attributes( + db: &dyn SyntaxGroup, + attribute_list: &AttributeList, + ref_attribute: &str, +) -> Vec { + let mut diagnostics = vec![]; + + for attribute in DOJO_ATTR_NAMES { + if attribute == ref_attribute { + if attribute_list.query_attr(db, attribute).first().is_some() { + diagnostics.push_error(format!( + "Only one {} attribute is allowed per module.", + ref_attribute + )); + } + } else { + if attribute_list.query_attr(db, attribute).first().is_some() { + diagnostics.push_error(format!( + "A {} can't be used together with a {}.", + ref_attribute, attribute + )); + } + } + } + + diagnostics +} + +/// Compute a unique hash based on the element name and types and names of members. +/// This hash is used in element contracts to ensure uniqueness. +pub fn compute_unique_hash( + db: &dyn SyntaxGroup, + element_name: &str, + is_packed: bool, + members: &[MemberAst], +) -> Felt { + let mut hashes = + vec![if is_packed { Felt::ONE } else { Felt::ZERO }, compute_bytearray_hash(element_name)]; + hashes.extend( + members + .iter() + .map(|m| { + poseidon_hash_many(&[ + compute_bytearray_hash(&m.name(db).text(db).to_string()), + compute_bytearray_hash( + m.type_clause(db).ty(db).as_syntax_node().get_text(db).trim(), + ), + ]) + }) + .collect::>(), + ); + poseidon_hash_many(&hashes) +} + +pub fn handle_struct_attribute_macro( + token_stream: TokenStream, + from_struct: fn(&dyn SyntaxGroup, &ast::ItemStruct) -> ProcMacroResult, +) -> ProcMacroResult { + let db = SimpleParserDatabase::default(); + let (root_node, _diagnostics) = db.parse_virtual_with_diagnostics(token_stream); + + for n in root_node.descendants(&db) { + if n.kind(&db) == ItemStruct { + let struct_ast = ast::ItemStruct::from_syntax_node(&db, n); + return from_struct(&db, &struct_ast); + } + } + + ProcMacroResult::new(TokenStream::empty()) +} diff --git a/crates/dojo/macros/src/derives/introspect/layout.rs b/crates/dojo/macros/src/derives/introspect/layout.rs new file mode 100644 index 0000000000..c38b5d8042 --- /dev/null +++ b/crates/dojo/macros/src/derives/introspect/layout.rs @@ -0,0 +1,338 @@ +use cairo_lang_macro::Diagnostic; +use cairo_lang_syntax::node::ast::{Expr, ItemEnum, ItemStruct, OptionTypeClause, TypeClause}; +use cairo_lang_syntax::node::db::SyntaxGroup; +use cairo_lang_syntax::node::helpers::QueryAttrs; +use cairo_lang_syntax::node::{Terminal, TypedSyntaxNode}; +use starknet::core::utils::get_selector_from_name; + +use super::utils::{ + get_array_item_type, get_tuple_item_types, is_array, is_byte_array, is_tuple, + is_unsupported_option_type, primitive_type_introspection, +}; +use crate::diagnostic_ext::DiagnosticsExt; + +/// build the full layout for every field in the Struct. +pub fn build_field_layouts( + db: &dyn SyntaxGroup, + diagnostics: &mut Vec, + struct_ast: &ItemStruct, +) -> String { + struct_ast + .members(db) + .elements(db) + .iter() + .filter_map(|m| { + if m.has_attr(db, "key") { + return None; + } + + let field_name = m.name(db).text(db); + let field_selector = get_selector_from_name(&field_name.to_string()).unwrap(); + let field_layout = get_layout_from_type_clause(db, diagnostics, &m.type_clause(db)); + Some(format!( + "dojo::meta::FieldLayout {{ + selector: {field_selector}, + layout: {field_layout} + }}" + )) + }) + .collect::>() + .join(",\n") +} + +/// build the full layout for every variant in the Enum. +/// Note that every variant may have a different associated data type. +pub fn build_variant_layouts( + db: &dyn SyntaxGroup, + diagnostics: &mut Vec, + enum_ast: &ItemEnum, +) -> String { + enum_ast + .variants(db) + .elements(db) + .iter() + .enumerate() + .map(|(i, v)| { + let selector = format!("{i}"); + + let variant_layout = match v.type_clause(db) { + OptionTypeClause::Empty(_) => { + "dojo::meta::Layout::Fixed(array![].span())".to_string() + } + OptionTypeClause::TypeClause(type_clause) => { + get_layout_from_type_clause(db, diagnostics, &type_clause) + } + }; + + format!( + "dojo::meta::FieldLayout {{ + selector: {selector}, + layout: {variant_layout} + }}" + ) + }) + .collect::>() + .join(",\n") +} + +/// Build a field layout describing the provided type clause. +pub fn get_layout_from_type_clause( + db: &dyn SyntaxGroup, + diagnostics: &mut Vec, + type_clause: &TypeClause, +) -> String { + match type_clause.ty(db) { + Expr::Path(path) => { + let path_type = path.as_syntax_node().get_text(db); + build_item_layout_from_type(diagnostics, &path_type) + } + Expr::Tuple(expr) => { + let tuple_type = expr.as_syntax_node().get_text(db); + build_tuple_layout_from_type(diagnostics, &tuple_type) + } + _ => { + diagnostics.push_error("Unexpected expression for variant data type.".to_string()); + "ERROR".to_string() + } + } +} + +/// Build the array layout describing the provided array type. +/// item_type could be something like `Array` for example. +pub fn build_array_layout_from_type(diagnostics: &mut Vec, item_type: &str) -> String { + let array_item_type = get_array_item_type(item_type); + + if is_tuple(&array_item_type) { + format!( + "dojo::meta::Layout::Array( + array![ + {} + ].span() + )", + build_item_layout_from_type(diagnostics, &array_item_type) + ) + } else if is_array(&array_item_type) { + format!( + "dojo::meta::Layout::Array( + array![ + {} + ].span() + )", + build_array_layout_from_type(diagnostics, &array_item_type) + ) + } else { + format!("dojo::meta::introspect::Introspect::<{}>::layout()", item_type) + } +} + +/// Build the tuple layout describing the provided tuple type. +/// item_type could be something like (u8, u32, u128) for example. +pub fn build_tuple_layout_from_type(diagnostics: &mut Vec, item_type: &str) -> String { + let tuple_items = get_tuple_item_types(item_type) + .iter() + .map(|x| build_item_layout_from_type(diagnostics, x)) + .collect::>() + .join(",\n"); + format!( + "dojo::meta::Layout::Tuple( + array![ + {} + ].span() + )", + tuple_items + ) +} + +/// Build the layout describing the provided type. +/// item_type could be any type (array, tuple, struct, ...) +pub fn build_item_layout_from_type(diagnostics: &mut Vec, item_type: &str) -> String { + if is_array(item_type) { + build_array_layout_from_type(diagnostics, item_type) + } else if is_tuple(item_type) { + build_tuple_layout_from_type(diagnostics, item_type) + } else { + // For Option, T cannot be a tuple + if is_unsupported_option_type(item_type) { + diagnostics.push_error( + "Option cannot be used with tuples. Prefer using a struct.".to_string(), + ); + } + + format!("dojo::meta::introspect::Introspect::<{}>::layout()", item_type) + } +} + +pub fn is_custom_layout(layout: &str) -> bool { + layout.starts_with("dojo::meta::introspect::Introspect::") +} + +pub fn build_packed_struct_layout( + db: &dyn SyntaxGroup, + diagnostics: &mut Vec, + struct_ast: &ItemStruct, +) -> String { + let layouts = struct_ast + .members(db) + .elements(db) + .iter() + .filter_map(|m| { + if m.has_attr(db, "key") { + return None; + } + + Some(get_packed_field_layout_from_type_clause(db, diagnostics, &m.type_clause(db))) + }) + .flatten() + .collect::>(); + + if layouts.iter().any(|v| is_custom_layout(v.as_str())) { + generate_cairo_code_for_fixed_layout_with_custom_types(&layouts) + } else { + format!( + "dojo::meta::Layout::Fixed( + array![ + {} + ].span() + )", + layouts.join(",") + ) + } +} + +pub fn generate_cairo_code_for_fixed_layout_with_custom_types(layouts: &[String]) -> String { + let layouts_repr = layouts + .iter() + .map(|l| { + if is_custom_layout(l) { + l.to_string() + } else { + format!("dojo::meta::Layout::Fixed(array![{l}].span())") + } + }) + .collect::>() + .join(",\n"); + + format!( + "let mut layouts = array![ + {layouts_repr} + ]; + let mut merged_layout = ArrayTrait::::new(); + + loop {{ + match ArrayTrait::pop_front(ref layouts) {{ + Option::Some(mut layout) => {{ + match layout {{ + dojo::meta::Layout::Fixed(mut l) => {{ + loop {{ + match SpanTrait::pop_front(ref l) {{ + Option::Some(x) => merged_layout.append(*x), + Option::None(_) => {{ break; }} + }}; + }}; + }}, + _ => panic!(\"A packed model layout must contain Fixed layouts only.\"), + }}; + }}, + Option::None(_) => {{ break; }} + }}; + }}; + + dojo::meta::Layout::Fixed(merged_layout.span()) + ", + ) +} + +// +pub fn build_packed_enum_layout( + db: &dyn SyntaxGroup, + diagnostics: &mut Vec, + enum_ast: &ItemEnum, +) -> String { + // to be packable, all variants data must have the same size. + // as this point has already been checked before calling `build_packed_enum_layout`, + // just use the first variant to generate the fixed layout. + let elements = enum_ast.variants(db).elements(db); + let mut variant_layout = if elements.is_empty() { + vec![] + } else { + match elements.first().unwrap().type_clause(db) { + OptionTypeClause::Empty(_) => vec![], + OptionTypeClause::TypeClause(type_clause) => { + get_packed_field_layout_from_type_clause(db, diagnostics, &type_clause) + } + } + }; + + // don't forget the store the variant value + variant_layout.insert(0, "8".to_string()); + + if variant_layout.iter().any(|v| is_custom_layout(v.as_str())) { + generate_cairo_code_for_fixed_layout_with_custom_types(&variant_layout) + } else { + format!( + "dojo::meta::Layout::Fixed( + array![ + {} + ].span() + )", + variant_layout.join(",") + ) + } +} + +// +pub fn get_packed_field_layout_from_type_clause( + db: &dyn SyntaxGroup, + diagnostics: &mut Vec, + type_clause: &TypeClause, +) -> Vec { + match type_clause.ty(db) { + Expr::Path(path) => { + let path_type = path.as_syntax_node().get_text(db); + get_packed_item_layout_from_type(diagnostics, path_type.trim()) + } + Expr::Tuple(expr) => { + let tuple_type = expr.as_syntax_node().get_text(db); + get_packed_tuple_layout_from_type(diagnostics, &tuple_type) + } + _ => { + diagnostics.push_error("Unexpected expression for variant data type.".to_string()); + vec!["ERROR".to_string()] + } + } +} + +// +pub fn get_packed_item_layout_from_type( + diagnostics: &mut Vec, + item_type: &str, +) -> Vec { + if is_array(item_type) || is_byte_array(item_type) { + diagnostics.push_error("Array field cannot be packed.".to_string()); + vec!["ERROR".to_string()] + } else if is_tuple(item_type) { + get_packed_tuple_layout_from_type(diagnostics, item_type) + } else { + let primitives = primitive_type_introspection(); + + if let Some(p) = primitives.get(item_type) { + vec![p.1.iter().map(|x| x.to_string()).collect::>().join(",")] + } else { + // as we cannot verify that an enum/struct custom type is packable, + // we suppose it is and let the user verify this. + // If it's not the case, the Dojo model layout function will panic. + vec![format!("dojo::meta::introspect::Introspect::<{}>::layout()", item_type)] + } + } +} + +// +pub fn get_packed_tuple_layout_from_type( + diagnostics: &mut Vec, + item_type: &str, +) -> Vec { + get_tuple_item_types(item_type) + .iter() + .flat_map(|x| get_packed_item_layout_from_type(diagnostics, x)) + .collect::>() +} diff --git a/crates/dojo/macros/src/derives/introspect/mod.rs b/crates/dojo/macros/src/derives/introspect/mod.rs new file mode 100644 index 0000000000..e01c4e4cd2 --- /dev/null +++ b/crates/dojo/macros/src/derives/introspect/mod.rs @@ -0,0 +1,154 @@ +use cairo_lang_defs::patcher::RewriteNode; +use cairo_lang_macro::Diagnostic; +use cairo_lang_syntax::node::ast::{ + GenericParam, ItemEnum, ItemStruct, OptionWrappedGenericParamList, +}; +use cairo_lang_syntax::node::db::SyntaxGroup; +use cairo_lang_syntax::node::Terminal; +use cairo_lang_utils::unordered_hash_map::UnorderedHashMap; + +use crate::diagnostic_ext::DiagnosticsExt; + +mod layout; +mod size; +mod ty; +mod utils; + +/// Generate the introspect of a Struct +pub fn handle_introspect_struct( + db: &dyn SyntaxGroup, + diagnostics: &mut Vec, + struct_ast: ItemStruct, + packed: bool, +) -> RewriteNode { + let struct_name = struct_ast.name(db).text(db).into(); + let struct_size = size::compute_struct_layout_size(db, &struct_ast, packed); + let ty = ty::build_struct_ty(db, &struct_name, &struct_ast); + + let layout = if packed { + layout::build_packed_struct_layout(db, diagnostics, &struct_ast) + } else { + format!( + "dojo::meta::Layout::Struct( + array![ + {} + ].span() + )", + layout::build_field_layouts(db, diagnostics, &struct_ast) + ) + }; + + let (gen_types, gen_impls) = build_generic_types_and_impls(db, struct_ast.generic_params(db)); + + generate_introspect(&struct_name, &struct_size, &gen_types, gen_impls, &layout, &ty) +} + +/// Generate the introspect of a Enum +pub fn handle_introspect_enum( + db: &dyn SyntaxGroup, + diagnostics: &mut Vec, + enum_ast: ItemEnum, + packed: bool, +) -> RewriteNode { + let enum_name = enum_ast.name(db).text(db).into(); + let variant_sizes = size::compute_enum_variant_sizes(db, &enum_ast); + + let layout = if packed { + if size::is_enum_packable(&variant_sizes) { + layout::build_packed_enum_layout(db, diagnostics, &enum_ast) + } else { + diagnostics.push_error( + "To be packed, all variants must have fixed layout of same size.".to_string(), + ); + "ERROR".to_string() + } + } else { + format!( + "dojo::meta::Layout::Enum( + array![ + {} + ].span() + )", + layout::build_variant_layouts(db, diagnostics, &enum_ast) + ) + }; + + let (gen_types, gen_impls) = build_generic_types_and_impls(db, enum_ast.generic_params(db)); + let enum_size = size::compute_enum_layout_size(&variant_sizes, packed); + let ty = ty::build_enum_ty(db, &enum_name, &enum_ast); + + generate_introspect(&enum_name, &enum_size, &gen_types, gen_impls, &layout, &ty) +} + +/// Generate the introspect impl for a Struct or an Enum, +/// based on its name, size, layout and Ty. +fn generate_introspect( + name: &String, + size: &String, + generic_types: &[String], + generic_impls: String, + layout: &String, + ty: &String, +) -> RewriteNode { + RewriteNode::interpolate_patched( + " +impl $name$Introspect<$generics$> of dojo::meta::introspect::Introspect<$name$<$generics_types$>> \ + { + #[inline(always)] + fn size() -> Option { + $size$ + } + + fn layout() -> dojo::meta::Layout { + $layout$ + } + + #[inline(always)] + fn ty() -> dojo::meta::introspect::Ty { + $ty$ + } +} + ", + &UnorderedHashMap::from([ + ("name".to_string(), RewriteNode::Text(name.to_string())), + ("generics".to_string(), RewriteNode::Text(generic_impls)), + ("generics_types".to_string(), RewriteNode::Text(generic_types.join(", "))), + ("size".to_string(), RewriteNode::Text(size.to_string())), + ("layout".to_string(), RewriteNode::Text(layout.to_string())), + ("ty".to_string(), RewriteNode::Text(ty.to_string())), + ]), + ) +} + +// Extract generic type information and build the +// type and impl information to add to the generated introspect +fn build_generic_types_and_impls( + db: &dyn SyntaxGroup, + generic_params: OptionWrappedGenericParamList, +) -> (Vec, String) { + let generic_types = + if let OptionWrappedGenericParamList::WrappedGenericParamList(params) = generic_params { + params + .generic_params(db) + .elements(db) + .iter() + .filter_map(|el| { + if let GenericParam::Type(typ) = el { + Some(typ.name(db).text(db).to_string()) + } else { + None + } + }) + .collect::>() + } else { + vec![] + }; + + let generic_impls = generic_types + .iter() + .map(|g| format!("{g}, impl {g}Introspect: dojo::meta::introspect::Introspect<{g}>")) + .collect::>() + .join(", "); + + (generic_types, generic_impls) +} diff --git a/crates/dojo/macros/src/derives/introspect/size.rs b/crates/dojo/macros/src/derives/introspect/size.rs new file mode 100644 index 0000000000..efb81ea25d --- /dev/null +++ b/crates/dojo/macros/src/derives/introspect/size.rs @@ -0,0 +1,200 @@ +use cairo_lang_syntax::node::ast::{Expr, ItemEnum, ItemStruct, OptionTypeClause, TypeClause}; +use cairo_lang_syntax::node::db::SyntaxGroup; +use cairo_lang_syntax::node::helpers::QueryAttrs; +use cairo_lang_syntax::node::TypedSyntaxNode; + +use super::utils::{ + get_tuple_item_types, is_array, is_byte_array, is_tuple, primitive_type_introspection, +}; + +pub fn compute_struct_layout_size( + db: &dyn SyntaxGroup, + struct_ast: &ItemStruct, + is_packed: bool, +) -> String { + let mut cumulated_sizes = 0; + let mut is_dynamic_size = false; + + let mut sizes = struct_ast + .members(db) + .elements(db) + .into_iter() + .filter_map(|m| { + if m.has_attr(db, "key") { + return None; + } + + let (sizes, cumulated, is_dynamic) = + get_field_size_from_type_clause(db, &m.type_clause(db)); + + cumulated_sizes += cumulated; + is_dynamic_size |= is_dynamic; + Some(sizes) + }) + .flatten() + .collect::>(); + build_size_function_body(&mut sizes, cumulated_sizes, is_dynamic_size, is_packed) +} + +pub fn compute_enum_variant_sizes( + db: &dyn SyntaxGroup, + enum_ast: &ItemEnum, +) -> Vec<(Vec, u32, bool)> { + enum_ast + .variants(db) + .elements(db) + .iter() + .map(|v| match v.type_clause(db) { + OptionTypeClause::Empty(_) => (vec![], 0, false), + OptionTypeClause::TypeClause(type_clause) => { + get_field_size_from_type_clause(db, &type_clause) + } + }) + .collect::>() +} + +pub fn is_enum_packable(variant_sizes: &[(Vec, u32, bool)]) -> bool { + if variant_sizes.is_empty() { + return true; + } + + let v0_sizes = variant_sizes[0].0.clone(); + let v0_fixed_size = variant_sizes[0].1; + + variant_sizes.iter().all(|vs| { + vs.0.len() == v0_sizes.len() + && vs.0.iter().zip(v0_sizes.iter()).all(|(a, b)| a == b) + && vs.1 == v0_fixed_size + && !vs.2 + }) +} + +pub fn compute_enum_layout_size( + variant_sizes: &[(Vec, u32, bool)], + is_packed: bool, +) -> String { + if variant_sizes.is_empty() { + return "Option::None".to_string(); + } + + let v0 = variant_sizes[0].clone(); + let identical_variants = + variant_sizes.iter().all(|vs| vs.0 == v0.0 && vs.1 == v0.1 && vs.2 == v0.2); + + if identical_variants { + let (mut sizes, mut cumulated_sizes, is_dynamic_size) = v0; + + // add one felt252 to store the variant identifier + cumulated_sizes += 1; + + build_size_function_body(&mut sizes, cumulated_sizes, is_dynamic_size, is_packed) + } else { + "Option::None".to_string() + } +} + +pub fn build_size_function_body( + sizes: &mut Vec, + cumulated_sizes: u32, + is_dynamic_size: bool, + is_packed: bool, +) -> String { + if is_dynamic_size { + return "Option::None".to_string(); + } + + if cumulated_sizes > 0 { + sizes.push(format!("Option::Some({})", cumulated_sizes)); + } + + match sizes.len() { + 0 => "Option::None".to_string(), + 1 => sizes[0].clone(), + _ => { + let none_check = if is_packed { + "" + } else { + "if dojo::utils::any_none(@sizes) { + return Option::None; + }" + }; + + format!( + "let sizes : Array> = array![ + {} + ]; + + {none_check} + Option::Some(dojo::utils::sum(sizes)) + ", + sizes.join(",\n") + ) + } + } +} + +pub fn get_field_size_from_type_clause( + db: &dyn SyntaxGroup, + type_clause: &TypeClause, +) -> (Vec, u32, bool) { + let mut cumulated_sizes = 0; + let mut is_dynamic_size = false; + + let field_sizes = match type_clause.ty(db) { + Expr::Path(path) => { + let path_type = path.as_syntax_node().get_text(db).trim().to_string(); + compute_item_size_from_type(&path_type) + } + Expr::Tuple(expr) => { + let tuple_type = expr.as_syntax_node().get_text(db).trim().to_string(); + compute_tuple_size_from_type(&tuple_type) + } + _ => { + // field type already checked while building the layout + vec!["ERROR".to_string()] + } + }; + + let sizes = field_sizes + .into_iter() + .filter_map(|s| match s.parse::() { + Ok(v) => { + cumulated_sizes += v; + None + } + Err(_) => { + if s.eq("Option::None") { + is_dynamic_size = true; + None + } else { + Some(s) + } + } + }) + .collect::>(); + + (sizes, cumulated_sizes, is_dynamic_size) +} + +pub fn compute_item_size_from_type(item_type: &String) -> Vec { + if is_array(item_type) || is_byte_array(item_type) { + vec!["Option::None".to_string()] + } else if is_tuple(item_type) { + compute_tuple_size_from_type(item_type) + } else { + let primitives = primitive_type_introspection(); + + if let Some(p) = primitives.get(item_type) { + vec![p.0.to_string()] + } else { + vec![format!("dojo::meta::introspect::Introspect::<{}>::size()", item_type)] + } + } +} + +pub fn compute_tuple_size_from_type(tuple_type: &str) -> Vec { + get_tuple_item_types(tuple_type) + .iter() + .flat_map(compute_item_size_from_type) + .collect::>() +} diff --git a/crates/dojo/macros/src/derives/introspect/ty.rs b/crates/dojo/macros/src/derives/introspect/ty.rs new file mode 100644 index 0000000000..d9e9e40a11 --- /dev/null +++ b/crates/dojo/macros/src/derives/introspect/ty.rs @@ -0,0 +1,133 @@ +use cairo_lang_syntax::node::ast::{ + Expr, ItemEnum, ItemStruct, Member, OptionTypeClause, TypeClause, Variant, +}; +use cairo_lang_syntax::node::db::SyntaxGroup; +use cairo_lang_syntax::node::helpers::QueryAttrs; +use cairo_lang_syntax::node::{Terminal, TypedSyntaxNode}; + +use super::utils::{get_array_item_type, get_tuple_item_types, is_array, is_byte_array, is_tuple}; + +pub fn build_struct_ty(db: &dyn SyntaxGroup, name: &String, struct_ast: &ItemStruct) -> String { + let members_ty = struct_ast + .members(db) + .elements(db) + .iter() + .map(|m| build_member_ty(db, m)) + .collect::>(); + + format!( + "dojo::meta::introspect::Ty::Struct( + dojo::meta::introspect::Struct {{ + name: '{name}', + attrs: array![].span(), + children: array![ + {}\n + ].span() + }} + )", + members_ty.join(",\n") + ) +} + +pub fn build_enum_ty(db: &dyn SyntaxGroup, name: &String, enum_ast: &ItemEnum) -> String { + let variants = enum_ast.variants(db).elements(db); + + let variants_ty = if variants.is_empty() { + "".to_string() + } else { + variants.iter().map(|v| build_variant_ty(db, v)).collect::>().join(",\n") + }; + + format!( + "dojo::meta::introspect::Ty::Enum( + dojo::meta::introspect::Enum {{ + name: '{name}', + attrs: array![].span(), + children: array![ + {variants_ty}\n + ].span() + }} + )" + ) +} + +pub fn build_member_ty(db: &dyn SyntaxGroup, member: &Member) -> String { + let name = member.name(db).text(db).to_string(); + let attrs = if member.has_attr(db, "key") { vec!["'key'"] } else { vec![] }; + + format!( + "dojo::meta::introspect::Member {{ + name: '{name}', + attrs: array![{}].span(), + ty: {} + }}", + attrs.join(","), + build_ty_from_type_clause(db, &member.type_clause(db)) + ) +} + +pub fn build_variant_ty(db: &dyn SyntaxGroup, variant: &Variant) -> String { + let name = variant.name(db).text(db).to_string(); + match variant.type_clause(db) { + OptionTypeClause::Empty(_) => { + // use an empty tuple if the variant has no data + format!("('{name}', dojo::meta::introspect::Ty::Tuple(array![].span()))") + } + OptionTypeClause::TypeClause(type_clause) => { + format!("('{name}', {})", build_ty_from_type_clause(db, &type_clause)) + } + } +} + +pub fn build_ty_from_type_clause(db: &dyn SyntaxGroup, type_clause: &TypeClause) -> String { + match type_clause.ty(db) { + Expr::Path(path) => { + let path_type = path.as_syntax_node().get_text(db).trim().to_string(); + build_item_ty_from_type(&path_type) + } + Expr::Tuple(expr) => { + let tuple_type = expr.as_syntax_node().get_text(db).trim().to_string(); + build_tuple_ty_from_type(&tuple_type) + } + _ => { + // diagnostic message already handled in layout building + "ERROR".to_string() + } + } +} + +pub fn build_item_ty_from_type(item_type: &String) -> String { + if is_array(item_type) { + let array_item_type = get_array_item_type(item_type); + format!( + "dojo::meta::introspect::Ty::Array( + array![ + {} + ].span() + )", + build_item_ty_from_type(&array_item_type) + ) + } else if is_byte_array(item_type) { + "dojo::meta::introspect::Ty::ByteArray".to_string() + } else if is_tuple(item_type) { + build_tuple_ty_from_type(item_type) + } else { + format!("dojo::meta::introspect::Introspect::<{}>::ty()", item_type) + } +} + +pub fn build_tuple_ty_from_type(item_type: &str) -> String { + let tuple_items = get_tuple_item_types(item_type) + .iter() + .map(build_item_ty_from_type) + .collect::>() + .join(",\n"); + format!( + "dojo::meta::introspect::Ty::Tuple( + array![ + {} + ].span() + )", + tuple_items + ) +} diff --git a/crates/dojo/macros/src/derives/introspect/utils.rs b/crates/dojo/macros/src/derives/introspect/utils.rs new file mode 100644 index 0000000000..f57f6b6335 --- /dev/null +++ b/crates/dojo/macros/src/derives/introspect/utils.rs @@ -0,0 +1,136 @@ +use std::collections::HashMap; + +#[derive(Clone, Default, Debug)] +pub struct TypeIntrospection(pub usize, pub Vec); + +// Provides type introspection information for primitive types +pub fn primitive_type_introspection() -> HashMap { + HashMap::from([ + ("bytes31".into(), TypeIntrospection(1, vec![248])), + ("felt252".into(), TypeIntrospection(1, vec![251])), + ("bool".into(), TypeIntrospection(1, vec![1])), + ("u8".into(), TypeIntrospection(1, vec![8])), + ("u16".into(), TypeIntrospection(1, vec![16])), + ("u32".into(), TypeIntrospection(1, vec![32])), + ("u64".into(), TypeIntrospection(1, vec![64])), + ("u128".into(), TypeIntrospection(1, vec![128])), + ("u256".into(), TypeIntrospection(2, vec![128, 128])), + ("usize".into(), TypeIntrospection(1, vec![32])), + ("ContractAddress".into(), TypeIntrospection(1, vec![251])), + ("ClassHash".into(), TypeIntrospection(1, vec![251])), + ]) +} + +/// Check if the provided type is an unsupported `Option`, +/// because tuples are not supported with Option. +pub fn is_unsupported_option_type(ty: &str) -> bool { + ty.starts_with("Option<(") +} + +pub fn is_byte_array(ty: &str) -> bool { + ty.eq("ByteArray") +} + +pub fn is_array(ty: &str) -> bool { + ty.starts_with("Array<") || ty.starts_with("Span<") +} + +pub fn is_tuple(ty: &str) -> bool { + ty.starts_with('(') +} + +pub fn get_array_item_type(ty: &str) -> String { + if ty.starts_with("Array<") { + ty.trim().strip_prefix("Array<").unwrap().strip_suffix('>').unwrap().to_string() + } else { + ty.trim().strip_prefix("Span<").unwrap().strip_suffix('>').unwrap().to_string() + } +} + +/// split a tuple in array of items (nested tuples are not splitted). +/// example (u8, (u16, u32), u128) -> ["u8", "(u16, u32)", "u128"] +pub fn get_tuple_item_types(ty: &str) -> Vec { + let tuple_str = ty + .trim() + .strip_prefix('(') + .unwrap() + .strip_suffix(')') + .unwrap() + .to_string() + .replace(' ', ""); + let mut items = vec![]; + let mut current_item = "".to_string(); + let mut level = 0; + + for c in tuple_str.chars() { + if c == ',' { + if level > 0 { + current_item.push(c); + } + + if level == 0 && !current_item.is_empty() { + items.push(current_item); + current_item = "".to_string(); + } + } else { + current_item.push(c); + + if c == '(' { + level += 1; + } + if c == ')' { + level -= 1; + } + } + } + + if !current_item.is_empty() { + items.push(current_item); + } + + items +} + +#[test] +pub fn test_get_tuple_item_types() { + pub fn assert_array(got: Vec, expected: Vec) { + pub fn format_array(arr: Vec) -> String { + format!("[{}]", arr.join(", ")) + } + + assert!( + got.len() == expected.len(), + "arrays have not the same length (got: {}, expected: {})", + format_array(got), + format_array(expected) + ); + + for i in 0..got.len() { + assert!( + got[i] == expected[i], + "unexpected array item: (got: {} expected: {})", + got[i], + expected[i] + ) + } + } + + let test_cases = vec![ + ("(u8,)", vec!["u8"]), + ("(u8, u16, u32)", vec!["u8", "u16", "u32"]), + ("(u8, (u16,), u32)", vec!["u8", "(u16,)", "u32"]), + ("(u8, (u16, (u8, u16)))", vec!["u8", "(u16,(u8,u16))"]), + ("(Array<(Points, Damage)>, ((u16,),)))", vec!["Array<(Points,Damage)>", "((u16,),))"]), + ( + "(u8, (u16, (u8, u16), Array<(Points, Damage)>), ((u16,),)))", + vec!["u8", "(u16,(u8,u16),Array<(Points,Damage)>)", "((u16,),))"], + ), + ]; + + for (value, expected) in test_cases { + assert_array( + get_tuple_item_types(value), + expected.iter().map(|x| x.to_string()).collect::>(), + ) + } +} diff --git a/crates/dojo/macros/src/derives/mod.rs b/crates/dojo/macros/src/derives/mod.rs new file mode 100644 index 0000000000..bbc018703f --- /dev/null +++ b/crates/dojo/macros/src/derives/mod.rs @@ -0,0 +1,230 @@ +//! Derive macros. +//! +//! A derive macros is a macro that is used to generate code generally for a struct or enum. +//! The input of the macro consists of the AST of the struct or enum and the attributes of the +//! derive macro. + +use cairo_lang_defs::patcher::{PatchBuilder, RewriteNode}; +use cairo_lang_macro::{derive_macro, Diagnostic, Diagnostics, ProcMacroResult, TokenStream}; +use cairo_lang_parser::utils::SimpleParserDatabase; +use cairo_lang_syntax::attribute::structured::{AttributeArgVariant, AttributeStructurize}; +use cairo_lang_syntax::node::ast::Attribute; +use cairo_lang_syntax::node::db::SyntaxGroup; +use cairo_lang_syntax::node::helpers::QueryAttrs; +use cairo_lang_syntax::node::kind::SyntaxKind::{ItemEnum, ItemStruct}; +use cairo_lang_syntax::node::{ast, Terminal, TypedSyntaxNode}; +use introspect::{handle_introspect_enum, handle_introspect_struct}; +use print::{handle_print_enum, handle_print_struct}; + +use crate::diagnostic_ext::DiagnosticsExt; + +pub mod introspect; +pub mod print; + +pub const DOJO_PRINT_DERIVE: &str = "Print"; +pub const DOJO_INTROSPECT_DERIVE: &str = "Introspect"; +pub const DOJO_PACKED_DERIVE: &str = "IntrospectPacked"; + +#[derive_macro] +fn introspect(token_stream: TokenStream) -> ProcMacroResult { + handle_derives_macros(token_stream) +} + +#[derive_macro] +fn introspect_packed(token_stream: TokenStream) -> ProcMacroResult { + handle_derives_macros(token_stream) +} + +pub fn handle_derives_macros(token_stream: TokenStream) -> ProcMacroResult { + let db = SimpleParserDatabase::default(); + let (syn_file, _diagnostics) = db.parse_virtual_with_diagnostics(token_stream); + + for n in syn_file.descendants(&db) { + // Process only the first module expected to be the contract. + return match n.kind(&db) { + ItemStruct => { + let struct_ast = ast::ItemStruct::from_syntax_node(&db, n); + let attrs = struct_ast.attributes(&db).query_attr(&db, "derive"); + + dojo_derive_all(&db, attrs, &ast::ModuleItem::Struct(struct_ast)) + } + ItemEnum => { + let enum_ast = ast::ItemEnum::from_syntax_node(&db, n); + let attrs = enum_ast.attributes(&db).query_attr(&db, "derive"); + + dojo_derive_all(&db, attrs, &ast::ModuleItem::Enum(enum_ast)) + } + _ => { + continue; + } + }; + } + + ProcMacroResult::new(TokenStream::empty()) +} + +/// Handles all the dojo derives macro and returns the generated code and diagnostics. +pub fn dojo_derive_all( + db: &dyn SyntaxGroup, + attrs: Vec, + item_ast: &ast::ModuleItem, +) -> ProcMacroResult { + if attrs.is_empty() { + return ProcMacroResult::new(TokenStream::empty()); + } + + let mut diagnostics = vec![]; + + let derive_attr_names = extract_derive_attr_names(db, &mut diagnostics, attrs); + + let (rewrite_nodes, derive_diagnostics) = handle_derive_attrs(db, &derive_attr_names, item_ast); + + diagnostics.extend(derive_diagnostics); + + let mut builder = PatchBuilder::new(db, item_ast); + for node in rewrite_nodes { + builder.add_modified(node); + } + + let (code, _) = builder.build(); + + let item_name = item_ast.as_syntax_node().get_text_without_trivia(db).to_string(); + + crate::debug_expand(&format!("DERIVE {}", item_name), &code.to_string()); + + return ProcMacroResult::new(TokenStream::new(code)) + .with_diagnostics(Diagnostics::new(diagnostics)); +} + +/// Handles the derive attributes of a struct or enum. +pub fn handle_derive_attrs( + db: &dyn SyntaxGroup, + attrs: &[String], + item_ast: &ast::ModuleItem, +) -> (Vec, Vec) { + let mut rewrite_nodes = Vec::new(); + let mut diagnostics = Vec::new(); + + check_for_derive_attr_conflicts(&mut diagnostics, attrs); + + match item_ast { + ast::ModuleItem::Struct(struct_ast) => { + for a in attrs { + match a.as_str() { + DOJO_PRINT_DERIVE => { + rewrite_nodes.push(handle_print_struct(db, struct_ast.clone())); + } + DOJO_INTROSPECT_DERIVE => { + rewrite_nodes.push(handle_introspect_struct( + db, + &mut diagnostics, + struct_ast.clone(), + false, + )); + } + DOJO_PACKED_DERIVE => { + rewrite_nodes.push(handle_introspect_struct( + db, + &mut diagnostics, + struct_ast.clone(), + true, + )); + } + _ => continue, + } + } + } + ast::ModuleItem::Enum(enum_ast) => { + for a in attrs { + match a.as_str() { + DOJO_PRINT_DERIVE => { + rewrite_nodes.push(handle_print_enum(db, enum_ast.clone())); + } + DOJO_INTROSPECT_DERIVE => { + rewrite_nodes.push(handle_introspect_enum( + db, + &mut diagnostics, + enum_ast.clone(), + false, + )); + } + DOJO_PACKED_DERIVE => { + rewrite_nodes.push(handle_introspect_enum( + db, + &mut diagnostics, + enum_ast.clone(), + true, + )); + } + _ => continue, + } + } + } + _ => { + // Currently Dojo plugin doesn't support derive macros on other items than struct and + // enum. + diagnostics.push_error( + "Dojo plugin doesn't support derive macros on other items than struct and enum." + .to_string(), + ); + } + } + + (rewrite_nodes, diagnostics) +} + +/// Extracts the names of the derive attributes from the given attributes. +/// +/// # Examples +/// +/// Derive usage should look like this: +/// +/// ```no_run,ignore +/// #[derive(Introspect)] +/// struct MyStruct {} +/// ``` +/// +/// And this function will return `["Introspect"]`. +pub fn extract_derive_attr_names( + db: &dyn SyntaxGroup, + diagnostics: &mut Vec, + attrs: Vec, +) -> Vec { + attrs + .iter() + .filter_map(|attr| { + let args = attr.clone().structurize(db).args; + if args.is_empty() { + diagnostics.push_error("Expected args.".to_string()); + None + } else { + Some(args.into_iter().filter_map(|a| { + if let AttributeArgVariant::Unnamed(ast::Expr::Path(path)) = a.variant { + if let [ast::PathSegment::Simple(segment)] = &path.elements(db)[..] { + Some(segment.ident(db).text(db).to_string()) + } else { + None + } + } else { + None + } + })) + } + }) + .flatten() + .collect::>() +} + +/// Checks for conflicts between introspect and packed attributes. +/// +/// Introspect and IntrospectPacked cannot be used at a same time. +fn check_for_derive_attr_conflicts(diagnostics: &mut Vec, attr_names: &[String]) { + if attr_names.contains(&DOJO_INTROSPECT_DERIVE.to_string()) + && attr_names.contains(&DOJO_PACKED_DERIVE.to_string()) + { + diagnostics.push_error(format!( + "{} and {} attributes cannot be used at a same time.", + DOJO_INTROSPECT_DERIVE, DOJO_PACKED_DERIVE + )); + } +} diff --git a/crates/dojo/macros/src/derives/print.rs b/crates/dojo/macros/src/derives/print.rs new file mode 100644 index 0000000000..999adf2622 --- /dev/null +++ b/crates/dojo/macros/src/derives/print.rs @@ -0,0 +1,96 @@ +use cairo_lang_defs::patcher::RewriteNode; +use cairo_lang_syntax::node::ast::{ItemEnum, ItemStruct, OptionTypeClause}; +use cairo_lang_syntax::node::db::SyntaxGroup; +use cairo_lang_syntax::node::{Terminal, TypedSyntaxNode}; +use cairo_lang_utils::unordered_hash_map::UnorderedHashMap; + +/// Derives PrintTrait for a struct. +/// Parameters: +/// * db: The semantic database. +/// * struct_ast: The AST of the model struct. +/// +/// Returns: +/// * A RewriteNode containing the generated code. +pub fn handle_print_struct(db: &dyn SyntaxGroup, struct_ast: ItemStruct) -> RewriteNode { + let prints: Vec<_> = struct_ast + .members(db) + .elements(db) + .iter() + .map(|m| { + format!( + "core::debug::PrintTrait::print('{}'); core::debug::PrintTrait::print(self.{});", + m.name(db).text(db).to_string(), + m.name(db).text(db).to_string() + ) + }) + .collect(); + + RewriteNode::interpolate_patched( + " +#[cfg(test)] +impl $type_name$StructPrintImpl of core::debug::PrintTrait<$type_name$> { + fn print(self: $type_name$) { + $print$ + } +} +", + &UnorderedHashMap::from([ + ( + "type_name".to_string(), + RewriteNode::new_trimmed(struct_ast.name(db).as_syntax_node()), + ), + ("print".to_string(), RewriteNode::Text(prints.join("\n"))), + ]), + ) +} + +/// Derives PrintTrait for an enum. +/// Parameters: +/// * db: The semantic database. +/// * enum_ast: The AST of the model enum. +/// +/// Returns: +/// * A RewriteNode containing the generated code. +pub fn handle_print_enum(db: &dyn SyntaxGroup, enum_ast: ItemEnum) -> RewriteNode { + let enum_name = enum_ast.name(db).text(db); + let prints: Vec<_> = enum_ast + .variants(db) + .elements(db) + .iter() + .map(|m| { + let variant_name = m.name(db).text(db).to_string(); + match m.type_clause(db) { + OptionTypeClause::Empty(_) => { + format!( + "{enum_name}::{variant_name} => {{ \ + core::debug::PrintTrait::print('{variant_name}'); }}" + ) + } + OptionTypeClause::TypeClause(_) => { + format!( + "{enum_name}::{variant_name}(v) => {{ \ + core::debug::PrintTrait::print('{variant_name}'); \ + core::debug::PrintTrait::print(v); }}" + ) + } + } + }) + .collect(); + + RewriteNode::interpolate_patched( + " +#[cfg(test)] +impl $type_name$EnumPrintImpl of core::debug::PrintTrait<$type_name$> { + fn print(self: $type_name$) { + match self { + $print$ + } + } +} +", + &UnorderedHashMap::from([ + ("type_name".to_string(), RewriteNode::new_trimmed(enum_ast.name(db).as_syntax_node())), + ("print".to_string(), RewriteNode::Text(prints.join(",\n"))), + ]), + ) +} diff --git a/crates/dojo/macros/src/diagnostic_ext.rs b/crates/dojo/macros/src/diagnostic_ext.rs new file mode 100644 index 0000000000..05b4d0032e --- /dev/null +++ b/crates/dojo/macros/src/diagnostic_ext.rs @@ -0,0 +1,16 @@ +use cairo_lang_macro::Diagnostic; + +pub trait DiagnosticsExt { + fn push_error(&mut self, message: String); + fn push_warn(&mut self, message: String); +} + +impl DiagnosticsExt for Vec { + fn push_error(&mut self, message: String) { + self.push(Diagnostic::error(message)); + } + + fn push_warn(&mut self, message: String) { + self.push(Diagnostic::warn(message)); + } +} diff --git a/crates/dojo/macros/src/inlines/mod.rs b/crates/dojo/macros/src/inlines/mod.rs new file mode 100644 index 0000000000..341596eade --- /dev/null +++ b/crates/dojo/macros/src/inlines/mod.rs @@ -0,0 +1 @@ +pub mod selector_from_tag; diff --git a/crates/dojo/macros/src/inlines/selector_from_tag.rs b/crates/dojo/macros/src/inlines/selector_from_tag.rs new file mode 100644 index 0000000000..d032846805 --- /dev/null +++ b/crates/dojo/macros/src/inlines/selector_from_tag.rs @@ -0,0 +1,59 @@ +use cairo_lang_defs::patcher::PatchBuilder; +use cairo_lang_macro::{inline_macro, ProcMacroResult, TokenStream}; +use cairo_lang_parser::utils::SimpleParserDatabase; +use cairo_lang_syntax::node::kind::SyntaxKind::ItemInlineMacro; +use cairo_lang_syntax::node::{ast, TypedSyntaxNode}; +use dojo_types::naming; + +use crate::proc_macro_result_ext::ProcMacroResultExt; + +#[inline_macro] +pub fn selector_from_tag(token_stream: TokenStream) -> ProcMacroResult { + handle_selector_from_tag_macro(token_stream) +} + +pub fn handle_selector_from_tag_macro(token_stream: TokenStream) -> ProcMacroResult { + let db = SimpleParserDatabase::default(); + let (root_node, _diagnostics) = db.parse_virtual_with_diagnostics(token_stream); + + for n in root_node.descendants(&db) { + if n.kind(&db) == ItemInlineMacro { + let node = ast::ItemInlineMacro::from_syntax_node(&db, n); + + let ast::WrappedArgList::ParenthesizedArgList(arg_list) = node.arguments(&db) else { + return ProcMacroResult::error( + "Macro `selector_from_tag!` does not support this bracket type.".to_string(), + ); + }; + + let args = arg_list.arguments(&db).elements(&db); + + if args.len() != 1 { + return ProcMacroResult::error( + "Invalid arguments. Expected \"selector_from_tag!(\"tag\")\"".to_string(), + ); + } + + let tag = &args[0].as_syntax_node().get_text(&db).replace('\"', ""); + + if !naming::is_valid_tag(tag) { + return ProcMacroResult::error( + "Invalid tag. Tag must be in the format of `namespace-name`.".to_string(), + ); + } + + let selector = naming::compute_selector_from_tag(tag); + + let mut builder = PatchBuilder::new(&db, &node); + builder.add_str(&format!("{:#64x}", selector)); + + let (code, _) = builder.build(); + + return ProcMacroResult::new(TokenStream::new(code)); + } + } + + ProcMacroResult::error( + "Macro `selector_from_tag!` must be called with a string parameter".to_string(), + ) +} diff --git a/crates/dojo/macros/src/lib.rs b/crates/dojo/macros/src/lib.rs new file mode 100644 index 0000000000..9225bc96b0 --- /dev/null +++ b/crates/dojo/macros/src/lib.rs @@ -0,0 +1,21 @@ +pub mod attributes; +pub mod derives; +pub mod diagnostic_ext; +pub mod inlines; +pub mod proc_macro_result_ext; + +#[cfg(test)] +pub mod tests; + +/// Prints the given string only if the `DOJO_EXPAND` environment variable is set. +/// This is useful for debugging the compiler with verbose output. +/// +/// # Arguments +/// +/// * `loc` - The location of the code to be expanded. +/// * `code` - The code to be expanded. +pub fn debug_expand(loc: &str, code: &str) { + if std::env::var("DOJO_EXPAND").is_ok() { + println!("\n// *> EXPAND {} <*\n{}\n\n", loc, code); + } +} diff --git a/crates/dojo/macros/src/proc_macro_result_ext.rs b/crates/dojo/macros/src/proc_macro_result_ext.rs new file mode 100644 index 0000000000..cd984a40af --- /dev/null +++ b/crates/dojo/macros/src/proc_macro_result_ext.rs @@ -0,0 +1,15 @@ +use cairo_lang_macro::{Diagnostics, ProcMacroResult, TokenStream}; + +use crate::diagnostic_ext::DiagnosticsExt; + +pub trait ProcMacroResultExt { + fn error(message: String) -> Self; +} + +impl ProcMacroResultExt for ProcMacroResult { + fn error(message: String) -> Self { + let mut diagnostics = vec![]; + diagnostics.push_error(message); + ProcMacroResult::new(TokenStream::empty()).with_diagnostics(Diagnostics::new(diagnostics)) + } +} diff --git a/crates/dojo/macros/src/tests/attributes/dojo_contract.rs b/crates/dojo/macros/src/tests/attributes/dojo_contract.rs new file mode 100644 index 0000000000..2ed97bdee9 --- /dev/null +++ b/crates/dojo/macros/src/tests/attributes/dojo_contract.rs @@ -0,0 +1,149 @@ +use cairo_lang_macro::TokenStream; + +use crate::attributes::constants::{DOJO_CONTRACT_ATTR, DOJO_MODEL_ATTR}; + +use crate::attributes::dojo_contract::{handle_module_attribute_macro, DOJO_INIT_FN}; +use crate::tests::utils::assert_output_stream; + +const SIMPLE_CONTRACT: &str = " +mod simple_contract { +} +"; + +const EXPANDED_SIMPLE_CONTRACT: &str = include_str!("./expanded/simple_contract.cairo"); + +const COMPLEX_CONTRACT: &str = " +mod complex_contract { + use starknet::{ContractAddress, get_caller_address}; + + #[derive(Copy, Drop, Serde)] + #[dojo::event] + struct MyInit { + #[key] + caller: ContractAddress, + value: u8, + } + + #[storage] + struct Storage { + value: u128 + } + + #[derive(Drop, starknet::Event)] + pub struct MyEvent { + #[key] + pub selector: felt252, + pub value: u64, + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + MyEvent: MyEvent, + } + + #[constructor] + fn constructor(ref self: ContractState) { + self.value.write(12); + } + + fn dojo_init(self: @ContractState, value: u8) { + let mut world = self.world(@\"ns\"); + world.emit_event(@MyInit { caller: get_caller_address(), value }); + } + + #[generate_trait] + impl SelfImpl of SelfTrait { + fn my_internal_function(self: @ContractState) -> u8 { + 42 + } + } +} +"; + +const EXPANDED_COMPLEX_CONTRACT: &str = include_str!("./expanded/complex_contract.cairo"); + +#[test] +fn test_contract_is_not_a_struct() { + let input = TokenStream::new("enum MyEnum { X, Y }".to_string()); + + let res = handle_module_attribute_macro(input); + + assert!(res.diagnostics.is_empty()); + assert!(res.token_stream.is_empty()); +} + +#[test] +fn test_contract_has_duplicated_attributes() { + let input = TokenStream::new(format!( + " + #[{DOJO_CONTRACT_ATTR}] + {SIMPLE_CONTRACT} + " + )); + + let res = handle_module_attribute_macro(input); + + assert_eq!( + res.diagnostics[0].message, + format!("Only one {DOJO_CONTRACT_ATTR} attribute is allowed per module.") + ); +} + +#[test] +fn test_contract_has_attribute_conflict() { + let input = TokenStream::new(format!( + " + #[{DOJO_MODEL_ATTR}] + {SIMPLE_CONTRACT} + " + )); + + let res = handle_module_attribute_macro(input); + + assert_eq!( + res.diagnostics[0].message, + format!("A {DOJO_CONTRACT_ATTR} can't be used together with a {DOJO_MODEL_ATTR}.") + ); +} + +#[test] +fn test_contract_has_bad_init_function() { + let input = TokenStream::new( + " +mod simple_contract { + fn dojo_init(self: @ContractState) -> u8 { + 0 + } +} + " + .to_string(), + ); + + let res = handle_module_attribute_macro(input); + + assert_eq!( + res.diagnostics[0].message, + format!("The {DOJO_INIT_FN} function cannot have a return type.") + ); +} + +#[test] +fn test_simple_contract() { + let input = TokenStream::new(SIMPLE_CONTRACT.to_string()); + + let res = handle_module_attribute_macro(input); + + assert!(res.diagnostics.is_empty()); + assert_output_stream(&res.token_stream, EXPANDED_SIMPLE_CONTRACT); +} + +#[test] +fn test_complex_contract() { + let input = TokenStream::new(COMPLEX_CONTRACT.to_string()); + + let res = handle_module_attribute_macro(input); + + assert!(res.diagnostics.is_empty()); + assert_output_stream(&res.token_stream, EXPANDED_COMPLEX_CONTRACT); +} diff --git a/crates/dojo/macros/src/tests/attributes/dojo_event.rs b/crates/dojo/macros/src/tests/attributes/dojo_event.rs new file mode 100644 index 0000000000..bce35a164d --- /dev/null +++ b/crates/dojo/macros/src/tests/attributes/dojo_event.rs @@ -0,0 +1,213 @@ +use cairo_lang_macro::TokenStream; + +use crate::attributes::constants::{DOJO_EVENT_ATTR, DOJO_MODEL_ATTR}; +use crate::attributes::dojo_event::handle_event_attribute_macro; +use crate::derives::DOJO_PACKED_DERIVE; +use crate::tests::utils::assert_output_stream; + +const SIMPLE_EVENT_WITHOUT_INTROSPECT: &str = " +#[derive(Drop, Serde)] +struct SimpleEvent { + #[key] + k: u32, + v: u32 +}"; + +const SIMPLE_EVENT: &str = " +#[derive(Introspect, Drop, Serde)] +struct SimpleEvent { + #[key] + k: u32, + v: u32 +}"; + +const EXPANDED_SIMPLE_EVENT: &str = include_str!("./expanded/simple_event.cairo"); + +const COMPLEX_EVENT: &str = " +#[derive(Introspect, Drop, Serde)] +struct ComplexEvent { + #[key] + k1: u8, + #[key] + k2: u32, + v1: u256, + v2: Option +}"; + +const EXPANDED_COMPLEX_EVENT: &str = include_str!("./expanded/complex_event.cairo"); + +#[test] +fn test_event_is_not_a_struct() { + let input = TokenStream::new("enum MyEnum { X, Y }".to_string()); + + let res = handle_event_attribute_macro(input); + + assert!(res.diagnostics.is_empty()); + assert!(res.token_stream.is_empty()); +} + +#[test] +fn test_event_has_duplicated_attributes() { + let input = TokenStream::new(format!( + " + #[{DOJO_EVENT_ATTR}] + {SIMPLE_EVENT} + " + )); + + let res = handle_event_attribute_macro(input); + + assert_eq!( + res.diagnostics[0].message, + format!("Only one {DOJO_EVENT_ATTR} attribute is allowed per module.") + ); +} + +#[test] +fn test_event_has_attribute_conflict() { + let input = TokenStream::new(format!( + " + #[{DOJO_MODEL_ATTR}] + {SIMPLE_EVENT} + " + )); + + let res = handle_event_attribute_macro(input); + + assert_eq!( + res.diagnostics[0].message, + format!("A {DOJO_EVENT_ATTR} can't be used together with a {DOJO_MODEL_ATTR}.") + ); +} + +#[test] +fn test_event_has_no_key() { + let input = TokenStream::new( + " + #[derive(Introspect, Drop, Serde)] + struct EventNoKey { + k: u32, + v: u32 + } + " + .to_string(), + ); + + let res = handle_event_attribute_macro(input); + + assert_eq!( + res.diagnostics[0].message, + "Event must define at least one #[key] attribute".to_string() + ); +} + +#[test] +fn test_event_has_no_value() { + let input = TokenStream::new( + " + #[derive(Introspect, Drop, Serde)] + struct EventNoValue { + #[key] + k: u32 + } + " + .to_string(), + ); + + let res = handle_event_attribute_macro(input); + + assert_eq!( + res.diagnostics[0].message, + "Event must define at least one member that is not a key".to_string() + ); +} + +#[test] +fn test_event_derives_from_introspect_packed() { + let input = TokenStream::new( + " + #[derive(IntrospectPacked, Drop, Serde)] + struct SimpleEvent { + #[key] + k: u32, + v: u32 + } + " + .to_string(), + ); + + let res = handle_event_attribute_macro(input); + + assert_eq!( + res.diagnostics[0].message, + format!("Deriving {DOJO_PACKED_DERIVE} on event is not allowed.") + ); +} + +#[test] +fn test_event_does_not_derive_from_drop() { + let input = TokenStream::new( + " + #[derive(Serde)] + struct SimpleModel { + #[key] + k: u32, + v: u32 + } + " + .to_string(), + ); + + let res = handle_event_attribute_macro(input); + + assert_eq!(res.diagnostics[0].message, "Event must derive from Drop and Serde.".to_string()); +} + +#[test] +fn test_event_does_not_derive_from_serde() { + let input = TokenStream::new( + " + #[derive(Drop)] + struct SimpleModel { + #[key] + k: u32, + v: u32 + } + " + .to_string(), + ); + + let res = handle_event_attribute_macro(input); + + assert_eq!(res.diagnostics[0].message, "Event must derive from Drop and Serde.".to_string()); +} + +#[test] +fn test_simple_event_without_introspect() { + let input = TokenStream::new(SIMPLE_EVENT_WITHOUT_INTROSPECT.to_string()); + + let res = handle_event_attribute_macro(input); + + assert!(res.diagnostics.is_empty()); + assert_output_stream(&res.token_stream, EXPANDED_SIMPLE_EVENT); +} + +#[test] +fn test_simple_event() { + let input = TokenStream::new(SIMPLE_EVENT.to_string()); + + let res = handle_event_attribute_macro(input); + + assert!(res.diagnostics.is_empty()); + assert_output_stream(&res.token_stream, EXPANDED_SIMPLE_EVENT); +} + +#[test] +fn test_complex_event() { + let input = TokenStream::new(COMPLEX_EVENT.to_string()); + + let res = handle_event_attribute_macro(input); + + assert!(res.diagnostics.is_empty()); + assert_output_stream(&res.token_stream, EXPANDED_COMPLEX_EVENT); +} diff --git a/crates/dojo/macros/src/tests/attributes/dojo_model.rs b/crates/dojo/macros/src/tests/attributes/dojo_model.rs new file mode 100644 index 0000000000..d5acac0711 --- /dev/null +++ b/crates/dojo/macros/src/tests/attributes/dojo_model.rs @@ -0,0 +1,212 @@ +use cairo_lang_macro::TokenStream; + +use crate::attributes::constants::{DOJO_EVENT_ATTR, DOJO_MODEL_ATTR}; +use crate::attributes::dojo_model::handle_model_attribute_macro; +use crate::tests::utils::assert_output_stream; + +const SIMPLE_MODEL: &str = " +#[derive(Introspect, Drop, Serde)] +struct SimpleModel { + #[key] + k: u32, + v: u32 +}"; + +const SIMPLE_MODEL_WITHOUT_INTROSPECT: &str = " +#[derive(Drop, Serde)] +struct SimpleModel { + #[key] + k: u32, + v: u32 +}"; + +const EXPANDED_SIMPLE_MODEL: &str = include_str!("./expanded/simple_model.cairo"); + +const COMPLEX_MODEL: &str = " +#[derive(Introspect, Drop, Serde)] +struct ComplexModel { + #[key] + k1: u8, + #[key] + k2: u32, + v1: u256, + v2: Option +}"; + +const EXPANDED_COMPLEX_MODEL: &str = include_str!("./expanded/complex_model.cairo"); + +#[test] +fn test_model_is_not_a_struct() { + let input = TokenStream::new("enum MyEnum { X, Y }".to_string()); + + let res = handle_model_attribute_macro(input); + + assert!(res.diagnostics.is_empty()); + assert!(res.token_stream.is_empty()); +} + +#[test] +fn test_model_has_duplicated_attributes() { + let input = TokenStream::new(format!( + " + #[{DOJO_MODEL_ATTR}] + {SIMPLE_MODEL} + " + )); + + let res = handle_model_attribute_macro(input); + + assert_eq!( + res.diagnostics[0].message, + format!("Only one {DOJO_MODEL_ATTR} attribute is allowed per module.") + ); +} + +#[test] +fn test_model_has_attribute_conflict() { + let input = TokenStream::new(format!( + " + #[{DOJO_EVENT_ATTR}] + {SIMPLE_MODEL} + " + )); + + let res = handle_model_attribute_macro(input); + + assert_eq!( + res.diagnostics[0].message, + format!("A {DOJO_MODEL_ATTR} can't be used together with a {DOJO_EVENT_ATTR}.") + ); +} + +#[test] +fn test_model_has_no_key() { + let input = TokenStream::new( + " + #[derive(Introspect, Drop, Serde)] + struct ModelNoKey { + k: u32, + v: u32 + } + " + .to_string(), + ); + + let res = handle_model_attribute_macro(input); + + assert_eq!( + res.diagnostics[0].message, + "Model must define at least one #[key] attribute".to_string() + ); +} + +#[test] +fn test_model_has_no_value() { + let input = TokenStream::new( + " + #[derive(Introspect, Drop, Serde)] + struct ModelNoValue { + #[key] + k: u32 + } + " + .to_string(), + ); + + let res = handle_model_attribute_macro(input); + + assert_eq!( + res.diagnostics[0].message, + "Model must define at least one member that is not a key".to_string() + ); +} + +#[test] +fn test_model_derives_from_both_introspect_and_packed() { + let input = TokenStream::new( + " + #[derive(Introspect, IntrospectPacked, Drop, Serde)] + struct SimpleModel { + #[key] + k: u32, + v: u32 + } + " + .to_string(), + ); + + let res = handle_model_attribute_macro(input); + + assert_eq!( + res.diagnostics[0].message, + "Model cannot derive from both Introspect and IntrospectPacked.".to_string() + ); +} + +#[test] +fn test_model_does_not_derive_from_drop() { + let input = TokenStream::new( + " + #[derive(Serde)] + struct SimpleModel { + #[key] + k: u32, + v: u32 + } + " + .to_string(), + ); + + let res = handle_model_attribute_macro(input); + + assert_eq!(res.diagnostics[0].message, "Model must derive from Drop and Serde.".to_string()); +} + +#[test] +fn test_model_does_not_derive_from_serde() { + let input = TokenStream::new( + " + #[derive(Drop)] + struct SimpleModel { + #[key] + k: u32, + v: u32 + } + " + .to_string(), + ); + + let res = handle_model_attribute_macro(input); + + assert_eq!(res.diagnostics[0].message, "Model must derive from Drop and Serde.".to_string()); +} + +#[test] +fn test_simple_model() { + let input = TokenStream::new(SIMPLE_MODEL.to_string()); + + let res = handle_model_attribute_macro(input); + + assert!(res.diagnostics.is_empty()); + assert_output_stream(&res.token_stream, EXPANDED_SIMPLE_MODEL); +} + +#[test] +fn test_simple_model_without_introspect() { + let input = TokenStream::new(SIMPLE_MODEL_WITHOUT_INTROSPECT.to_string()); + + let res = handle_model_attribute_macro(input); + + assert!(res.diagnostics.is_empty()); + assert_output_stream(&res.token_stream, EXPANDED_SIMPLE_MODEL); +} + +#[test] +fn test_complex_model() { + let input = TokenStream::new(COMPLEX_MODEL.to_string()); + + let res = handle_model_attribute_macro(input); + + assert!(res.diagnostics.is_empty()); + assert_output_stream(&res.token_stream, EXPANDED_COMPLEX_MODEL); +} diff --git a/crates/dojo/macros/src/tests/attributes/expanded/complex_contract.cairo b/crates/dojo/macros/src/tests/attributes/expanded/complex_contract.cairo new file mode 100644 index 0000000000..a301a7f661 --- /dev/null +++ b/crates/dojo/macros/src/tests/attributes/expanded/complex_contract.cairo @@ -0,0 +1,106 @@ +#[starknet::contract] +pub mod complex_contract { + use dojo::contract::components::world_provider::{ + world_provider_cpt, world_provider_cpt::InternalTrait as WorldProviderInternal, + IWorldProvider + }; + use dojo::contract::components::upgradeable::upgradeable_cpt; + use dojo::contract::IContract; + use dojo::meta::IDeployedResource; + + component!(path: world_provider_cpt, storage: world_provider, event: WorldProviderEvent); + component!(path: upgradeable_cpt, storage: upgradeable, event: UpgradeableEvent); + + #[abi(embed_v0)] + impl WorldProviderImpl = world_provider_cpt::WorldProviderImpl; + + #[abi(embed_v0)] + impl UpgradeableImpl = upgradeable_cpt::UpgradeableImpl; + + #[abi(embed_v0)] + pub impl complex_contract__ContractImpl of IContract {} + + #[abi(embed_v0)] + pub impl complex_contract__DeployedContractImpl of IDeployedResource { + fn dojo_name(self: @ContractState) -> ByteArray { + "complex_contract" + } + } + + #[generate_trait] + impl complex_contractInternalImpl of complex_contractInternalTrait { + fn world( + self: @ContractState, namespace: @ByteArray + ) -> dojo::world::storage::WorldStorage { + dojo::world::WorldStorageTrait::new(self.world_provider.world_dispatcher(), namespace) + } + } + + use starknet::{ContractAddress, get_caller_address}; + + #[derive(Copy, Drop, Serde)] + #[dojo::event] + struct MyInit { + #[key] + caller: ContractAddress, + value: u8, + } + + #[storage] + struct Storage { + #[substorage(v0)] + upgradeable: upgradeable_cpt::Storage, + #[substorage(v0)] + world_provider: world_provider_cpt::Storage, + value: u128 + } + + #[derive(Drop, starknet::Event)] + pub struct MyEvent { + #[key] + pub selector: felt252, + pub value: u64, + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + UpgradeableEvent: upgradeable_cpt::Event, + WorldProviderEvent: world_provider_cpt::Event, + MyEvent: MyEvent + } + + #[constructor] + fn constructor(ref self: ContractState) { + self.world_provider.initializer(); + self.value.write(12); + } + + #[abi(per_item)] + #[generate_trait] + pub impl IDojoInitImpl of IDojoInit { + #[external(v0)] + fn dojo_init(self: @ContractState, value: u8) { + if starknet::get_caller_address() != self + .world_provider + .world_dispatcher() + .contract_address { + core::panics::panic_with_byte_array( + @format!( + "Only the world can init contract `{}`, but caller is `{:?}`", + self.dojo_name(), + starknet::get_caller_address() + ) + ); + } + let mut world = self.world(@"ns"); + world.emit_event(@MyInit { caller: get_caller_address(), value }); + } + } + #[generate_trait] + impl SelfImpl of SelfTrait { + fn my_internal_function(self: @ContractState) -> u8 { + 42 + } + } +} diff --git a/crates/dojo/macros/src/tests/attributes/expanded/complex_event.cairo b/crates/dojo/macros/src/tests/attributes/expanded/complex_event.cairo new file mode 100644 index 0000000000..429888c065 --- /dev/null +++ b/crates/dojo/macros/src/tests/attributes/expanded/complex_event.cairo @@ -0,0 +1,89 @@ +#[derive(Introspect)] +struct ComplexEvent { + #[key] + k1: u8, + #[key] + k2: u32, + v1: u256, + v2: Option +} + +// EventValue on it's own does nothing since events are always emitted and +// never read from the storage. However, it's required by the ABI to +// ensure that the event definition contains both keys and values easily distinguishable. +// Only derives strictly required traits. +#[derive(Drop, Serde)] +pub struct ComplexEventValue { + pub v1: u256, + pub v2: Option, +} + +pub impl ComplexEventDefinition of dojo::event::EventDefinition { + #[inline(always)] + fn name() -> ByteArray { + "ComplexEvent" + } +} + +pub impl ComplexEventModelParser of dojo::model::model::ModelParser { + fn serialize_keys(self: @ComplexEvent) -> Span { + let mut serialized = core::array::ArrayTrait::new(); + core::serde::Serde::serialize(self.k1, ref serialized); + core::serde::Serde::serialize(self.k2, ref serialized); + + core::array::ArrayTrait::span(@serialized) + } + fn serialize_values(self: @ComplexEvent) -> Span { + let mut serialized = core::array::ArrayTrait::new(); + core::serde::Serde::serialize(self.v1, ref serialized); + core::serde::Serde::serialize(self.v2, ref serialized); + + core::array::ArrayTrait::span(@serialized) + } +} + +pub impl ComplexEventEventImpl = dojo::event::event::EventImpl; + +#[starknet::contract] +pub mod e_ComplexEvent { + use super::ComplexEvent; + use super::ComplexEventValue; + + #[storage] + struct Storage {} + + #[abi(embed_v0)] + impl ComplexEvent__DeployedEventImpl = + dojo::event::component::IDeployedEventImpl; + + #[abi(embed_v0)] + impl ComplexEvent__StoredEventImpl = + dojo::event::component::IStoredEventImpl; + + #[abi(embed_v0)] + impl ComplexEvent__EventImpl = + dojo::event::component::IEventImpl; + + #[abi(per_item)] + #[generate_trait] + impl ComplexEventImpl of IComplexEvent { + // Ensures the ABI contains the Event struct, since it's never used + // by systems directly. + #[external(v0)] + fn ensure_abi(self: @ContractState, event: ComplexEvent) { + let _event = event; + } + + // Outputs EventValue to allow a simple diff from the ABI compared to the + // event to retrieved the keys of an event. + #[external(v0)] + fn ensure_values(self: @ContractState, value: ComplexEventValue) { + let _value = value; + } + + // Ensures the generated contract has a unique classhash, using + // a hardcoded hash computed on event and member names. + #[external(v0)] + fn ensure_unique(self: @ContractState) {} + } +} diff --git a/crates/dojo/macros/src/tests/attributes/expanded/complex_model.cairo b/crates/dojo/macros/src/tests/attributes/expanded/complex_model.cairo new file mode 100644 index 0000000000..2b9daeb044 --- /dev/null +++ b/crates/dojo/macros/src/tests/attributes/expanded/complex_model.cairo @@ -0,0 +1,140 @@ +#[derive(Introspect)] +struct ComplexModel { + #[key] + k1: u8, + #[key] + k2: u32, + v1: u256, + v2: Option +} + +#[derive(Drop, Serde)] +pub struct ComplexModelValue { + pub v1: u256, + pub v2: Option, +} + +type ComplexModelKeyType = (u8, u32); + +pub impl ComplexModelKeyParser of dojo::model::model::KeyParser { + #[inline(always)] + fn parse_key(self: @ComplexModel) -> ComplexModelKeyType { + (*self.k1, *self.k2) + } +} + +impl ComplexModelModelValueKey of dojo::model::model_value::ModelValueKey< + ComplexModelValue, ComplexModelKeyType +> {} + +// Impl to get the static definition of a model +pub mod m_ComplexModel_definition { + use super::ComplexModel; + pub impl ComplexModelDefinitionImpl of dojo::model::ModelDefinition { + #[inline(always)] + fn name() -> ByteArray { + "ComplexModel" + } + + #[inline(always)] + fn layout() -> dojo::meta::Layout { + dojo::meta::Introspect::::layout() + } + + #[inline(always)] + fn schema() -> dojo::meta::introspect::Struct { + if let dojo::meta::introspect::Ty::Struct(s) = + dojo::meta::Introspect::::ty() { + s + } else { + panic!("Model `ComplexModel`: invalid schema.") + } + } + + #[inline(always)] + fn size() -> Option { + dojo::meta::Introspect::::size() + } + } +} + +pub impl ComplexModelDefinition = + m_ComplexModel_definition::ComplexModelDefinitionImpl; +pub impl ComplexModelModelValueDefinition = + m_ComplexModel_definition::ComplexModelDefinitionImpl; + +pub impl ComplexModelModelParser of dojo::model::model::ModelParser { + fn serialize_keys(self: @ComplexModel) -> Span { + let mut serialized = core::array::ArrayTrait::new(); + core::serde::Serde::serialize(self.k1, ref serialized); + core::serde::Serde::serialize(self.k2, ref serialized); + + core::array::ArrayTrait::span(@serialized) + } + fn serialize_values(self: @ComplexModel) -> Span { + let mut serialized = core::array::ArrayTrait::new(); + core::serde::Serde::serialize(self.v1, ref serialized); + core::serde::Serde::serialize(self.v2, ref serialized); + + core::array::ArrayTrait::span(@serialized) + } +} + +pub impl ComplexModelModelValueParser of dojo::model::model_value::ModelValueParser< + ComplexModelValue +> { + fn serialize_values(self: @ComplexModelValue) -> Span { + let mut serialized = core::array::ArrayTrait::new(); + core::serde::Serde::serialize(self.v1, ref serialized); + core::serde::Serde::serialize(self.v2, ref serialized); + + core::array::ArrayTrait::span(@serialized) + } +} + +pub impl ComplexModelModelImpl = dojo::model::model::ModelImpl; +pub impl ComplexModelModelValueImpl = dojo::model::model_value::ModelValueImpl; + +#[starknet::contract] +pub mod m_ComplexModel { + use super::ComplexModel; + use super::ComplexModelValue; + + #[storage] + struct Storage {} + + #[abi(embed_v0)] + impl ComplexModel__DojoDeployedModelImpl = + dojo::model::component::IDeployedModelImpl; + + #[abi(embed_v0)] + impl ComplexModel__DojoStoredModelImpl = + dojo::model::component::IStoredModelImpl; + + #[abi(embed_v0)] + impl ComplexModel__DojoModelImpl = + dojo::model::component::IModelImpl; + + #[abi(per_item)] + #[generate_trait] + impl ComplexModelImpl of IComplexModel { + // Ensures the ABI contains the Model struct, even if never used + // into as a system input. + #[external(v0)] + fn ensure_abi(self: @ContractState, model: ComplexModel) { + let _model = model; + } + + // Outputs ModelValue to allow a simple diff from the ABI compared to the + // model to retrieved the keys of a model. + #[external(v0)] + fn ensure_values(self: @ContractState, value: ComplexModelValue) { + let _value = value; + } + + // Ensures the generated contract has a unique classhash, using + // a hardcoded hash computed on model and member names. + #[external(v0)] + fn ensure_unique(self: @ContractState) {} + } +} diff --git a/crates/dojo/macros/src/tests/attributes/expanded/simple_contract.cairo b/crates/dojo/macros/src/tests/attributes/expanded/simple_contract.cairo new file mode 100644 index 0000000000..848ac02431 --- /dev/null +++ b/crates/dojo/macros/src/tests/attributes/expanded/simple_contract.cairo @@ -0,0 +1,78 @@ +#[starknet::contract] +pub mod simple_contract { + use dojo::contract::components::world_provider::{ + world_provider_cpt, world_provider_cpt::InternalTrait as WorldProviderInternal, + IWorldProvider + }; + use dojo::contract::components::upgradeable::upgradeable_cpt; + use dojo::contract::IContract; + use dojo::meta::IDeployedResource; + + component!(path: world_provider_cpt, storage: world_provider, event: WorldProviderEvent); + component!(path: upgradeable_cpt, storage: upgradeable, event: UpgradeableEvent); + + #[abi(embed_v0)] + impl WorldProviderImpl = world_provider_cpt::WorldProviderImpl; + + #[abi(embed_v0)] + impl UpgradeableImpl = upgradeable_cpt::UpgradeableImpl; + + #[abi(embed_v0)] + pub impl simple_contract__ContractImpl of IContract {} + + #[abi(embed_v0)] + pub impl simple_contract__DeployedContractImpl of IDeployedResource { + fn dojo_name(self: @ContractState) -> ByteArray { + "simple_contract" + } + } + + #[generate_trait] + impl simple_contractInternalImpl of simple_contractInternalTrait { + fn world( + self: @ContractState, namespace: @ByteArray + ) -> dojo::world::storage::WorldStorage { + dojo::world::WorldStorageTrait::new(self.world_provider.world_dispatcher(), namespace) + } + } + + + #[constructor] + fn constructor(ref self: ContractState) { + self.world_provider.initializer(); + } + #[abi(per_item)] + #[generate_trait] + pub impl IDojoInitImpl of IDojoInit { + #[external(v0)] + fn dojo_init(self: @ContractState) { + if starknet::get_caller_address() != self + .world_provider + .world_dispatcher() + .contract_address { + core::panics::panic_with_byte_array( + @format!( + "Only the world can init contract `{}`, but caller is `{:?}`", + self.dojo_name(), + starknet::get_caller_address(), + ) + ); + } + } + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + UpgradeableEvent: upgradeable_cpt::Event, + WorldProviderEvent: world_provider_cpt::Event, + } + + #[storage] + struct Storage { + #[substorage(v0)] + upgradeable: upgradeable_cpt::Storage, + #[substorage(v0)] + world_provider: world_provider_cpt::Storage, + } +} diff --git a/crates/dojo/macros/src/tests/attributes/expanded/simple_event.cairo b/crates/dojo/macros/src/tests/attributes/expanded/simple_event.cairo new file mode 100644 index 0000000000..caa83a6e87 --- /dev/null +++ b/crates/dojo/macros/src/tests/attributes/expanded/simple_event.cairo @@ -0,0 +1,83 @@ +#[derive(Introspect)] +struct SimpleEvent { + #[key] + k: u32, + v: u32 +} + +// EventValue on it's own does nothing since events are always emitted and +// never read from the storage. However, it's required by the ABI to +// ensure that the event definition contains both keys and values easily distinguishable. +// Only derives strictly required traits. +#[derive(Drop, Serde)] +pub struct SimpleEventValue { + pub v: u32, +} + +pub impl SimpleEventDefinition of dojo::event::EventDefinition { + #[inline(always)] + fn name() -> ByteArray { + "SimpleEvent" + } +} + +pub impl SimpleEventModelParser of dojo::model::model::ModelParser { + fn serialize_keys(self: @SimpleEvent) -> Span { + let mut serialized = core::array::ArrayTrait::new(); + core::serde::Serde::serialize(self.k, ref serialized); + + core::array::ArrayTrait::span(@serialized) + } + fn serialize_values(self: @SimpleEvent) -> Span { + let mut serialized = core::array::ArrayTrait::new(); + core::serde::Serde::serialize(self.v, ref serialized); + + core::array::ArrayTrait::span(@serialized) + } +} + +pub impl SimpleEventEventImpl = dojo::event::event::EventImpl; + +#[starknet::contract] +pub mod e_SimpleEvent { + use super::SimpleEvent; + use super::SimpleEventValue; + + #[storage] + struct Storage {} + + #[abi(embed_v0)] + impl SimpleEvent__DeployedEventImpl = + dojo::event::component::IDeployedEventImpl; + + #[abi(embed_v0)] + impl SimpleEvent__StoredEventImpl = + dojo::event::component::IStoredEventImpl; + + #[abi(embed_v0)] + impl SimpleEvent__EventImpl = + dojo::event::component::IEventImpl; + + #[abi(per_item)] + #[generate_trait] + impl SimpleEventImpl of ISimpleEvent { + // Ensures the ABI contains the Event struct, since it's never used + // by systems directly. + #[external(v0)] + fn ensure_abi(self: @ContractState, event: SimpleEvent) { + let _event = event; + } + + // Outputs EventValue to allow a simple diff from the ABI compared to the + // event to retrieved the keys of an event. + #[external(v0)] + fn ensure_values(self: @ContractState, value: SimpleEventValue) { + let _value = value; + } + + // Ensures the generated contract has a unique classhash, using + // a hardcoded hash computed on event and member names. + #[external(v0)] + fn ensure_unique(self: @ContractState) {} + } +} diff --git a/crates/dojo/macros/src/tests/attributes/expanded/simple_model.cairo b/crates/dojo/macros/src/tests/attributes/expanded/simple_model.cairo new file mode 100644 index 0000000000..36323ca14b --- /dev/null +++ b/crates/dojo/macros/src/tests/attributes/expanded/simple_model.cairo @@ -0,0 +1,132 @@ +#[derive(Introspect)] +struct SimpleModel { + #[key] + k: u32, + v: u32 +} + +#[derive(Drop, Serde)] +pub struct SimpleModelValue { + pub v: u32, +} + +type SimpleModelKeyType = u32; + +pub impl SimpleModelKeyParser of dojo::model::model::KeyParser { + #[inline(always)] + fn parse_key(self: @SimpleModel) -> SimpleModelKeyType { + *self.k + } +} + +impl SimpleModelModelValueKey of dojo::model::model_value::ModelValueKey< + SimpleModelValue, SimpleModelKeyType +> {} + +// Impl to get the static definition of a model +pub mod m_SimpleModel_definition { + use super::SimpleModel; + pub impl SimpleModelDefinitionImpl of dojo::model::ModelDefinition { + #[inline(always)] + fn name() -> ByteArray { + "SimpleModel" + } + + #[inline(always)] + fn layout() -> dojo::meta::Layout { + dojo::meta::Introspect::::layout() + } + + #[inline(always)] + fn schema() -> dojo::meta::introspect::Struct { + if let dojo::meta::introspect::Ty::Struct(s) = + dojo::meta::Introspect::::ty() { + s + } else { + panic!("Model `SimpleModel`: invalid schema.") + } + } + + #[inline(always)] + fn size() -> Option { + dojo::meta::Introspect::::size() + } + } +} + +pub impl SimpleModelDefinition = m_SimpleModel_definition::SimpleModelDefinitionImpl; +pub impl SimpleModelModelValueDefinition = + m_SimpleModel_definition::SimpleModelDefinitionImpl; + +pub impl SimpleModelModelParser of dojo::model::model::ModelParser { + fn serialize_keys(self: @SimpleModel) -> Span { + let mut serialized = core::array::ArrayTrait::new(); + core::serde::Serde::serialize(self.k, ref serialized); + + core::array::ArrayTrait::span(@serialized) + } + fn serialize_values(self: @SimpleModel) -> Span { + let mut serialized = core::array::ArrayTrait::new(); + core::serde::Serde::serialize(self.v, ref serialized); + + core::array::ArrayTrait::span(@serialized) + } +} + +pub impl SimpleModelModelValueParser of dojo::model::model_value::ModelValueParser< + SimpleModelValue +> { + fn serialize_values(self: @SimpleModelValue) -> Span { + let mut serialized = core::array::ArrayTrait::new(); + core::serde::Serde::serialize(self.v, ref serialized); + + core::array::ArrayTrait::span(@serialized) + } +} + +pub impl SimpleModelModelImpl = dojo::model::model::ModelImpl; +pub impl SimpleModelModelValueImpl = dojo::model::model_value::ModelValueImpl; + +#[starknet::contract] +pub mod m_SimpleModel { + use super::SimpleModel; + use super::SimpleModelValue; + + #[storage] + struct Storage {} + + #[abi(embed_v0)] + impl SimpleModel__DojoDeployedModelImpl = + dojo::model::component::IDeployedModelImpl; + + #[abi(embed_v0)] + impl SimpleModel__DojoStoredModelImpl = + dojo::model::component::IStoredModelImpl; + + #[abi(embed_v0)] + impl SimpleModel__DojoModelImpl = + dojo::model::component::IModelImpl; + + #[abi(per_item)] + #[generate_trait] + impl SimpleModelImpl of ISimpleModel { + // Ensures the ABI contains the Model struct, even if never used + // into as a system input. + #[external(v0)] + fn ensure_abi(self: @ContractState, model: SimpleModel) { + let _model = model; + } + + // Outputs ModelValue to allow a simple diff from the ABI compared to the + // model to retrieved the keys of a model. + #[external(v0)] + fn ensure_values(self: @ContractState, value: SimpleModelValue) { + let _value = value; + } + + // Ensures the generated contract has a unique classhash, using + // a hardcoded hash computed on model and member names. + #[external(v0)] + fn ensure_unique(self: @ContractState) {} + } +} diff --git a/crates/dojo/macros/src/tests/derives/expanded/complex_enum.cairo b/crates/dojo/macros/src/tests/derives/expanded/complex_enum.cairo new file mode 100644 index 0000000000..ba61d4fa31 --- /dev/null +++ b/crates/dojo/macros/src/tests/derives/expanded/complex_enum.cairo @@ -0,0 +1,57 @@ +impl ComplexEnumIntrospect<> of dojo::meta::introspect::Introspect> { + #[inline(always)] + fn size() -> Option { + Option::None + } + + fn layout() -> dojo::meta::Layout { + dojo::meta::Layout::Enum( + array![ + dojo::meta::FieldLayout { + selector: 0, layout: dojo::meta::introspect::Introspect::::layout() + }, + dojo::meta::FieldLayout { + selector: 1, layout: dojo::meta::introspect::Introspect::>::layout() + }, + dojo::meta::FieldLayout { + selector: 2, + layout: dojo::meta::Layout::Tuple( + array![ + dojo::meta::introspect::Introspect::::layout(), + dojo::meta::introspect::Introspect::::layout(), + dojo::meta::introspect::Introspect::::layout() + ] + .span() + ) + } + ] + .span() + ) + } + + #[inline(always)] + fn ty() -> dojo::meta::introspect::Ty { + dojo::meta::introspect::Ty::Enum( + dojo::meta::introspect::Enum { + name: 'ComplexEnum', + attrs: array![].span(), + children: array![ + ('VARIANT1', dojo::meta::introspect::Introspect::::ty()), + ('VARIANT2', dojo::meta::introspect::Introspect::>::ty()), + ( + 'VARIANT3', + dojo::meta::introspect::Ty::Tuple( + array![ + dojo::meta::introspect::Introspect::::ty(), + dojo::meta::introspect::Introspect::::ty(), + dojo::meta::introspect::Introspect::::ty() + ] + .span() + ) + ) + ] + .span() + } + ) + } +} diff --git a/crates/dojo/macros/src/tests/derives/expanded/complex_struct.cairo b/crates/dojo/macros/src/tests/derives/expanded/complex_struct.cairo new file mode 100644 index 0000000000..e3d04e9520 --- /dev/null +++ b/crates/dojo/macros/src/tests/derives/expanded/complex_struct.cairo @@ -0,0 +1,82 @@ +impl ComplexStructIntrospect<> of dojo::meta::introspect::Introspect> { + #[inline(always)] + fn size() -> Option { + Option::None + } + + fn layout() -> dojo::meta::Layout { + dojo::meta::Layout::Struct( + array![ + dojo::meta::FieldLayout { + selector: 687013198911006804117413256380548377255056948723479227932116677690621743639, + layout: dojo::meta::introspect::Introspect::>::layout() + }, + dojo::meta::FieldLayout { + selector: 573200779692275582020388969134054872186051594998702457223229675092771367647, + layout: dojo::meta::introspect::Introspect::>::layout() + }, + dojo::meta::FieldLayout { + selector: 268067745408767739723108330020913373797853558774636706294407751171317330906, + layout: dojo::meta::Layout::Tuple( + array![ + dojo::meta::introspect::Introspect::>::layout(), + dojo::meta::introspect::Introspect::::layout(), + dojo::meta::introspect::Introspect::>::layout() + ] + .span() + ) + } + ] + .span() + ) + } + + #[inline(always)] + fn ty() -> dojo::meta::introspect::Ty { + dojo::meta::introspect::Ty::Struct( + dojo::meta::introspect::Struct { + name: 'ComplexStruct', + attrs: array![].span(), + children: array![ + dojo::meta::introspect::Member { + name: 'k1', + attrs: array!['key'].span(), + ty: dojo::meta::introspect::Introspect::::ty() + }, + dojo::meta::introspect::Member { + name: 'k2', + attrs: array!['key'].span(), + ty: dojo::meta::introspect::Introspect::::ty() + }, + dojo::meta::introspect::Member { + name: 'v1', + attrs: array![].span(), + ty: dojo::meta::introspect::Ty::Array( + array![dojo::meta::introspect::Introspect::::ty()].span() + ) + }, + dojo::meta::introspect::Member { + name: 'v2', + attrs: array![].span(), + ty: dojo::meta::introspect::Introspect::>::ty() + }, + dojo::meta::introspect::Member { + name: 'v3', + attrs: array![].span(), + ty: dojo::meta::introspect::Ty::Tuple( + array![ + dojo::meta::introspect::Ty::Array( + array![dojo::meta::introspect::Introspect::::ty()].span() + ), + dojo::meta::introspect::Introspect::::ty(), + dojo::meta::introspect::Introspect::>::ty() + ] + .span() + ) + } + ] + .span() + } + ) + } +} diff --git a/crates/dojo/macros/src/tests/derives/expanded/packed_enum.cairo b/crates/dojo/macros/src/tests/derives/expanded/packed_enum.cairo new file mode 100644 index 0000000000..7a993979c2 --- /dev/null +++ b/crates/dojo/macros/src/tests/derives/expanded/packed_enum.cairo @@ -0,0 +1,87 @@ +impl PackedEnumIntrospect<> of dojo::meta::introspect::Introspect> { + #[inline(always)] + fn size() -> Option { + Option::Some(3) + } + + fn layout() -> dojo::meta::Layout { + dojo::meta::Layout::Enum( + array![ + dojo::meta::FieldLayout { + selector: 0, + layout: dojo::meta::Layout::Tuple( + array![ + dojo::meta::introspect::Introspect::::layout(), + dojo::meta::introspect::Introspect::::layout() + ] + .span() + ) + }, + dojo::meta::FieldLayout { + selector: 1, + layout: dojo::meta::Layout::Tuple( + array![ + dojo::meta::introspect::Introspect::::layout(), + dojo::meta::introspect::Introspect::::layout() + ] + .span() + ) + }, + dojo::meta::FieldLayout { + selector: 2, + layout: dojo::meta::Layout::Tuple( + array![ + dojo::meta::introspect::Introspect::::layout(), + dojo::meta::introspect::Introspect::::layout() + ] + .span() + ) + } + ] + .span() + ) + } + + #[inline(always)] + fn ty() -> dojo::meta::introspect::Ty { + dojo::meta::introspect::Ty::Enum( + dojo::meta::introspect::Enum { + name: 'PackedEnum', + attrs: array![].span(), + children: array![ + ( + 'VARIANT1', + dojo::meta::introspect::Ty::Tuple( + array![ + dojo::meta::introspect::Introspect::::ty(), + dojo::meta::introspect::Introspect::::ty() + ] + .span() + ) + ), + ( + 'VARIANT2', + dojo::meta::introspect::Ty::Tuple( + array![ + dojo::meta::introspect::Introspect::::ty(), + dojo::meta::introspect::Introspect::::ty() + ] + .span() + ) + ), + ( + 'VARIANT3', + dojo::meta::introspect::Ty::Tuple( + array![ + dojo::meta::introspect::Introspect::::ty(), + dojo::meta::introspect::Introspect::::ty() + ] + .span() + ) + ) + ] + .span() + } + ) + } +} diff --git a/crates/dojo/macros/src/tests/derives/expanded/packed_struct.cairo b/crates/dojo/macros/src/tests/derives/expanded/packed_struct.cairo new file mode 100644 index 0000000000..dc305e79df --- /dev/null +++ b/crates/dojo/macros/src/tests/derives/expanded/packed_struct.cairo @@ -0,0 +1,44 @@ +impl SimpleStructIntrospect<> of dojo::meta::introspect::Introspect> { + #[inline(always)] + fn size() -> Option { + Option::Some(3) + } + + fn layout() -> dojo::meta::Layout { + dojo::meta::Layout::Fixed(array![32, 8, 16].span()) + } + + #[inline(always)] + fn ty() -> dojo::meta::introspect::Ty { + dojo::meta::introspect::Ty::Struct( + dojo::meta::introspect::Struct { + name: 'SimpleStruct', + attrs: array![].span(), + children: array![ + dojo::meta::introspect::Member { + name: 'k1', + attrs: array!['key'].span(), + ty: dojo::meta::introspect::Introspect::::ty() + }, + dojo::meta::introspect::Member { + name: 'v1', + attrs: array![].span(), + ty: dojo::meta::introspect::Introspect::::ty() + }, + dojo::meta::introspect::Member { + name: 'v2', + attrs: array![].span(), + ty: dojo::meta::introspect::Ty::Tuple( + array![ + dojo::meta::introspect::Introspect::::ty(), + dojo::meta::introspect::Introspect::::ty() + ] + .span() + ) + } + ] + .span() + } + ) + } +} diff --git a/crates/dojo/macros/src/tests/derives/expanded/simple_enum.cairo b/crates/dojo/macros/src/tests/derives/expanded/simple_enum.cairo new file mode 100644 index 0000000000..aee76638fa --- /dev/null +++ b/crates/dojo/macros/src/tests/derives/expanded/simple_enum.cairo @@ -0,0 +1,39 @@ +impl SimpleEnumIntrospect<> of dojo::meta::introspect::Introspect> { + #[inline(always)] + fn size() -> Option { + Option::Some(1) + } + + fn layout() -> dojo::meta::Layout { + dojo::meta::Layout::Enum( + array![ + dojo::meta::FieldLayout { + selector: 0, layout: dojo::meta::Layout::Fixed(array![].span()) + }, + dojo::meta::FieldLayout { + selector: 1, layout: dojo::meta::Layout::Fixed(array![].span()) + }, + dojo::meta::FieldLayout { + selector: 2, layout: dojo::meta::Layout::Fixed(array![].span()) + } + ] + .span() + ) + } + + #[inline(always)] + fn ty() -> dojo::meta::introspect::Ty { + dojo::meta::introspect::Ty::Enum( + dojo::meta::introspect::Enum { + name: 'SimpleEnum', + attrs: array![].span(), + children: array![ + ('VARIANT1', dojo::meta::introspect::Ty::Tuple(array![].span())), + ('VARIANT2', dojo::meta::introspect::Ty::Tuple(array![].span())), + ('VARIANT3', dojo::meta::introspect::Ty::Tuple(array![].span())) + ] + .span() + } + ) + } +} diff --git a/crates/dojo/macros/src/tests/derives/expanded/simple_struct.cairo b/crates/dojo/macros/src/tests/derives/expanded/simple_struct.cairo new file mode 100644 index 0000000000..9a797e8b20 --- /dev/null +++ b/crates/dojo/macros/src/tests/derives/expanded/simple_struct.cairo @@ -0,0 +1,62 @@ +impl SimpleStructIntrospect<> of dojo::meta::introspect::Introspect> { + #[inline(always)] + fn size() -> Option { + Option::Some(3) + } + + fn layout() -> dojo::meta::Layout { + dojo::meta::Layout::Struct( + array![ + dojo::meta::FieldLayout { + selector: 687013198911006804117413256380548377255056948723479227932116677690621743639, + layout: dojo::meta::introspect::Introspect::::layout() + }, + dojo::meta::FieldLayout { + selector: 573200779692275582020388969134054872186051594998702457223229675092771367647, + layout: dojo::meta::Layout::Tuple( + array![ + dojo::meta::introspect::Introspect::::layout(), + dojo::meta::introspect::Introspect::::layout() + ] + .span() + ) + } + ] + .span() + ) + } + + #[inline(always)] + fn ty() -> dojo::meta::introspect::Ty { + dojo::meta::introspect::Ty::Struct( + dojo::meta::introspect::Struct { + name: 'SimpleStruct', + attrs: array![].span(), + children: array![ + dojo::meta::introspect::Member { + name: 'k1', + attrs: array!['key'].span(), + ty: dojo::meta::introspect::Introspect::::ty() + }, + dojo::meta::introspect::Member { + name: 'v1', + attrs: array![].span(), + ty: dojo::meta::introspect::Introspect::::ty() + }, + dojo::meta::introspect::Member { + name: 'v2', + attrs: array![].span(), + ty: dojo::meta::introspect::Ty::Tuple( + array![ + dojo::meta::introspect::Introspect::::ty(), + dojo::meta::introspect::Introspect::::ty() + ] + .span() + ) + } + ] + .span() + } + ) + } +} diff --git a/crates/dojo/macros/src/tests/derives/introspect.rs b/crates/dojo/macros/src/tests/derives/introspect.rs new file mode 100644 index 0000000000..430495c83e --- /dev/null +++ b/crates/dojo/macros/src/tests/derives/introspect.rs @@ -0,0 +1,201 @@ +use cairo_lang_macro::TokenStream; + +use crate::{derives::handle_derives_macros, tests::utils::assert_output_stream}; + +const SIMPLE_STRUCT: &str = " +#[derive(Introspect)] +struct SimpleStruct { + #[key] + k1: u256, + v1: u32, + v2: (u8, u16), +} +"; + +const EXPANDED_SIMPLE_STRUCT: &str = include_str!("./expanded/simple_struct.cairo"); + +const PACKED_STRUCT: &str = " +#[derive(IntrospectPacked)] +struct SimpleStruct { + #[key] + k1: u256, + v1: u32, + v2: (u8, u16), +} +"; + +const EXPANDED_PACKED_STRUCT: &str = include_str!("./expanded/packed_struct.cairo"); + +const COMPLEX_STRUCT: &str = " +#[derive(Introspect)] +struct ComplexStruct { + #[key] + k1: u256, + #[key] + k2: u32, + v1: Array, + v2: Option, + v3: (Array, u16, Option) +} +"; + +const EXPANDED_COMPLEX_STRUCT: &str = include_str!("./expanded/complex_struct.cairo"); + +const SIMPLE_ENUM: &str = " +#[derive(Introspect)] +enum SimpleEnum { + VARIANT1, + VARIANT2, + VARIANT3 +} +"; + +const EXPANDED_SIMPLE_ENUM: &str = include_str!("./expanded/simple_enum.cairo"); + +const PACKED_ENUM: &str = " +#[derive(Introspect)] +enum PackedEnum { + VARIANT1: (u32, u128), + VARIANT2: (u32, u128), + VARIANT3: (u32, u128), +} +"; + +const EXPANDED_PACKED_ENUM: &str = include_str!("./expanded/packed_enum.cairo"); + +const COMPLEX_ENUM: &str = " +#[derive(Introspect)] +enum ComplexEnum { + VARIANT1: u32, + VARIANT2: Option, + VARIANT3: (u8, u16, u32) +} +"; + +const EXPANDED_COMPLEX_ENUM: &str = include_str!("./expanded/complex_enum.cairo"); + +#[test] +fn test_bad_type() { + let input = TokenStream::new("mod my_module {}".to_string()); + + let res = handle_derives_macros(input); + + assert!(res.diagnostics.is_empty()); + assert!(res.token_stream.is_empty()); +} + +#[test] +fn test_attribute_conflict() { + let input = TokenStream::new( + "#[derive(Introspect, IntrospectPacked)] + struct MyStruct { + v: u32 + }" + .to_string(), + ); + + let res = handle_derives_macros(input); + + assert_eq!( + res.diagnostics[0].message, + format!("Introspect and IntrospectPacked attributes cannot be used at a same time.") + ); +} + +#[test] +fn test_tuple_in_option_error() { + let input = TokenStream::new( + "#[derive(Introspect)] + enum MyEnum { + V1: Option<(u8, u32)> + V2: Option<(u8, u32)> + }" + .to_string(), + ); + + let res = handle_derives_macros(input); + + assert_eq!( + res.diagnostics[0].message, + format!("Option cannot be used with tuples. Prefer using a struct.") + ); +} + +#[test] +fn test_bad_enum_for_introspect_packed() { + let input = TokenStream::new( + "#[derive(IntrospectPacked)] + enum MyEnum { + V1: Option, + V2: u128 + }" + .to_string(), + ); + + let res = handle_derives_macros(input); + + assert_eq!( + res.diagnostics[0].message, + format!("To be packed, all variants must have fixed layout of same size.") + ); +} + +#[test] +fn test_simple_struct() { + let input = TokenStream::new(SIMPLE_STRUCT.to_string()); + + let res = handle_derives_macros(input); + + assert!(res.diagnostics.is_empty()); + assert_output_stream(&res.token_stream, EXPANDED_SIMPLE_STRUCT); +} + +#[test] +fn test_packed_struct() { + let input = TokenStream::new(PACKED_STRUCT.to_string()); + + let res = handle_derives_macros(input); + + assert!(res.diagnostics.is_empty()); + assert_output_stream(&res.token_stream, EXPANDED_PACKED_STRUCT); +} + +#[test] +fn test_complex_struct() { + let input = TokenStream::new(COMPLEX_STRUCT.to_string()); + + let res = handle_derives_macros(input); + + assert!(res.diagnostics.is_empty()); + assert_output_stream(&res.token_stream, EXPANDED_COMPLEX_STRUCT); +} + +#[test] +fn test_simple_enum() { + let input = TokenStream::new(SIMPLE_ENUM.to_string()); + + let res = handle_derives_macros(input); + + assert!(res.diagnostics.is_empty()); + assert_output_stream(&res.token_stream, EXPANDED_SIMPLE_ENUM); +} + +#[test] +fn test_packed_enum() { + let input = TokenStream::new(PACKED_ENUM.to_string()); + + let res = handle_derives_macros(input); + + assert!(res.diagnostics.is_empty()); + assert_output_stream(&res.token_stream, EXPANDED_PACKED_ENUM); +} + +#[test] +fn test_complex_enum() { + let input = TokenStream::new(COMPLEX_ENUM.to_string()); + + let res = handle_derives_macros(input); + + assert!(res.diagnostics.is_empty()); + assert_output_stream(&res.token_stream, EXPANDED_COMPLEX_ENUM); +} diff --git a/crates/dojo/macros/src/tests/inlines/selector_from_tag.rs b/crates/dojo/macros/src/tests/inlines/selector_from_tag.rs new file mode 100644 index 0000000000..893d1123b8 --- /dev/null +++ b/crates/dojo/macros/src/tests/inlines/selector_from_tag.rs @@ -0,0 +1,65 @@ +use cairo_lang_macro::TokenStream; +use dojo_types::naming; + +use crate::inlines::selector_from_tag::handle_selector_from_tag_macro; + +#[test] +fn test_with_bad_type() { + let input = TokenStream::new("enum MyEnum { X, Y }".to_string()); + + let res = handle_selector_from_tag_macro(input); + + assert_eq!( + res.diagnostics[0].message, + "Macro `selector_from_tag!` must be called with a string parameter".to_string() + ); +} + +#[test] +fn test_with_bad_argument_type() { + let input = TokenStream::new("selector_from_tag![\"one\"]".to_string()); + + let res = handle_selector_from_tag_macro(input); + + assert_eq!( + res.diagnostics[0].message, + "Macro `selector_from_tag!` does not support this bracket type.".to_string() + ); +} + +#[test] +fn test_with_multiple_arguments() { + let input = TokenStream::new("selector_from_tag!(\"one\", \"two\")".to_string()); + + let res = handle_selector_from_tag_macro(input); + + assert_eq!( + res.diagnostics[0].message, + "Invalid arguments. Expected \"selector_from_tag!(\"tag\")\"".to_string() + ); +} + +#[test] +fn test_with_bad_tag() { + let input = TokenStream::new("selector_from_tag!(\"my_contract\")".to_string()); + + let res = handle_selector_from_tag_macro(input); + + assert_eq!( + res.diagnostics[0].message, + "Invalid tag. Tag must be in the format of `namespace-name`.".to_string() + ); +} + +#[test] +fn test_nominal_case() { + let input = TokenStream::new("selector_from_tag!(\"dojo-my_contract\")".to_string()); + + let res = handle_selector_from_tag_macro(input); + + assert!(res.diagnostics.is_empty()); + assert_eq!( + res.token_stream.to_string(), + format!("{:#64x}", naming::compute_selector_from_tag("dojo-my_contract")) + ); +} diff --git a/crates/dojo/macros/src/tests/mod.rs b/crates/dojo/macros/src/tests/mod.rs new file mode 100644 index 0000000000..2937e9229e --- /dev/null +++ b/crates/dojo/macros/src/tests/mod.rs @@ -0,0 +1,15 @@ +mod attributes { + mod dojo_contract; + mod dojo_event; + mod dojo_model; +} + +mod derives { + mod introspect; +} + +mod inlines { + mod selector_from_tag; +} + +mod utils; diff --git a/crates/dojo/macros/src/tests/utils.rs b/crates/dojo/macros/src/tests/utils.rs new file mode 100644 index 0000000000..dee23f949e --- /dev/null +++ b/crates/dojo/macros/src/tests/utils.rs @@ -0,0 +1,26 @@ +use cairo_lang_macro::TokenStream; +use regex::Regex; + +/// Asserts that the output token stream is as expected. +/// +/// #Arguments +/// `output` - the output token stream +/// `expected` - the expected output +pub(crate) fn assert_output_stream(output: &TokenStream, expected: &str) { + // to avoid differences due to formatting, we remove all the whitespaces + // and newlines. + fn trim_whitespaces_and_newlines(s: &str) -> String { + s.replace(" ", "").replace("\n", "") + } + + // the `ensure_unique` function contains a randomly generated + // hash, so we have to remove it to be able to compare. + let re = Regex::new(r"let _hash =.*;").unwrap(); + let output = output.to_string(); + let output = re.replace(&output, ""); + + let output = trim_whitespaces_and_newlines(&output); + let expected = trim_whitespaces_and_newlines(expected); + + assert_eq!(output, expected); +} diff --git a/crates/torii/types-test/Scarb.lock b/crates/torii/types-test/Scarb.lock index 0d453bfdcb..ca8cf36aa8 100644 --- a/crates/torii/types-test/Scarb.lock +++ b/crates/torii/types-test/Scarb.lock @@ -5,16 +5,16 @@ version = 1 name = "dojo" version = "1.0.1" dependencies = [ - "dojo_plugin", + "dojo_macros", ] [[package]] -name = "dojo_plugin" -version = "2.8.4" +name = "dojo_macros" +version = "0.1.0" [[package]] name = "types_test" -version = "1.0.0" +version = "1.0.1" dependencies = [ "dojo", ] diff --git a/examples/game-lib/Scarb.lock b/examples/game-lib/Scarb.lock index 775fb46b9b..10275b4aa2 100644 --- a/examples/game-lib/Scarb.lock +++ b/examples/game-lib/Scarb.lock @@ -17,11 +17,11 @@ dependencies = [ [[package]] name = "dojo" -version = "1.0.0-rc.0" +version = "1.0.1" dependencies = [ - "dojo_plugin", + "dojo_macros", ] [[package]] -name = "dojo_plugin" -version = "2.8.4" +name = "dojo_macros" +version = "0.1.0" diff --git a/examples/simple/Scarb.lock b/examples/simple/Scarb.lock index 4067faed93..ee5d82cbd7 100644 --- a/examples/simple/Scarb.lock +++ b/examples/simple/Scarb.lock @@ -3,9 +3,9 @@ version = 1 [[package]] name = "dojo" -version = "1.0.0" +version = "1.0.1" dependencies = [ - "dojo_plugin", + "dojo_macros", ] [[package]] @@ -16,8 +16,8 @@ dependencies = [ ] [[package]] -name = "dojo_plugin" -version = "2.8.4" +name = "dojo_macros" +version = "0.1.0" [[package]] name = "dojo_simple" diff --git a/examples/spawn-and-move/Scarb.lock b/examples/spawn-and-move/Scarb.lock index a3ce4d9320..d6e2d4a4f6 100644 --- a/examples/spawn-and-move/Scarb.lock +++ b/examples/spawn-and-move/Scarb.lock @@ -19,7 +19,7 @@ dependencies = [ name = "dojo" version = "1.0.1" dependencies = [ - "dojo_plugin", + "dojo_macros", ] [[package]] @@ -31,7 +31,7 @@ dependencies = [ [[package]] name = "dojo_examples" -version = "1.0.0" +version = "1.0.1" dependencies = [ "armory", "bestiary", @@ -40,5 +40,5 @@ dependencies = [ ] [[package]] -name = "dojo_plugin" -version = "2.8.4" +name = "dojo_macros" +version = "0.1.0"