From a3f809db915d3f98e2fc5055e45828bed961fda1 Mon Sep 17 00:00:00 2001 From: Nenad Date: Thu, 24 Oct 2024 21:29:16 +0200 Subject: [PATCH 01/16] Add metadata_uri --- land_registry/src/land_nft.cairo | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/land_registry/src/land_nft.cairo b/land_registry/src/land_nft.cairo index 4d6f9461..5dfbb005 100644 --- a/land_registry/src/land_nft.cairo +++ b/land_registry/src/land_nft.cairo @@ -8,6 +8,8 @@ pub trait ILandNFT { fn transfer( ref self: TContractState, from: ContractAddress, to: ContractAddress, token_id: u256 ); + fn update_metadata_uri(ref self: TContractState, new_metadata_uri: ByteArray); + fn metadata_uri(self: @TContractState) -> ByteArray; } #[starknet::contract] @@ -35,6 +37,7 @@ pub mod LandNFT { #[substorage(v0)] src5: SRC5Component::Storage, land_registry: ContractAddress, + metadata_uri: ByteArray, } #[event] @@ -47,9 +50,12 @@ pub mod LandNFT { } #[constructor] - fn constructor(ref self: ContractState, land_registry: ContractAddress) { + fn constructor( + ref self: ContractState, land_registry: ContractAddress, metadata_uri: ByteArray + ) { self.erc721.initializer("Land NFT", "LAND", format!("")); self.land_registry.write(land_registry); + self.metadata_uri.write(metadata_uri); } #[abi(embed_v0)] @@ -73,5 +79,18 @@ pub mod LandNFT { ); self.erc721.transfer(from, to, token_id); } + + fn update_metadata_uri(ref self: ContractState, new_metadata_uri: ByteArray) { + // Only the land registry contract can update the metadata URI + assert( + starknet::get_caller_address() == self.land_registry.read(), + 'Only land registry can update' + ); + self.metadata_uri.write(new_metadata_uri); + } + + fn metadata_uri(self: @ContractState) -> ByteArray { + self.metadata_uri.read() + } } } From 322ba6e123231baa49722ee4ff8359425c2fba2b Mon Sep 17 00:00:00 2001 From: Nenad Date: Thu, 24 Oct 2024 21:34:22 +0200 Subject: [PATCH 02/16] start work on tests --- land_registry/tests/test_land_nft.cairo | 36 +++++++++++-------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/land_registry/tests/test_land_nft.cairo b/land_registry/tests/test_land_nft.cairo index c25342c6..01685386 100644 --- a/land_registry/tests/test_land_nft.cairo +++ b/land_registry/tests/test_land_nft.cairo @@ -1,24 +1,18 @@ -// ************************************************************************* -// Setup -// ************************************************************************* -// #[cfg(test)] -// mod tests { -// use starknet::{ContractAddress, contract_address_const}; -// use land_registry::interface::{ -// ILandRegistryDispatcher, ILandRegistryDispatcherTrait, Land, LandUse -// }; -// use land_registry::land_register::LandRegistryContract; -// use land_registry::land_nft::{ILandNFTDispatcher, ILandNFTDispatcherTrait}; +use starknet::{ContractAddress, contract_address_const}; +use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, start_cheat_caller_address}; +use land_registry::interface::{ + ILandRegistryDispatcher, ILandRegistryDispatcherTrait, Land, LandUse +}; +use land_registry::land_register::LandRegistryContract; +use land_registry::land_nft::{ILandNFTDispatcher, ILandNFTDispatcherTrait}; +// fn deploy(name: ByteArray) -> ContractAddress { +// let nft_contract = declare("LandNFT").unwrap().contract_class(); +// let (nft_address, _) = nft_contract.deploy(@array![Accounts::nft().into()]).unwrap(); -// // Function NFT contract deployment -// fn deploy_land_nft(land_registry: ContractAddress) -> ILandNFTDispatcher { -// let nft_address: ContractAddress = contract_address_const::<0x456>(); -// ILandNFTDispatcher { contract_address: nft_address } -// } +// let land_registry_contract = declare(name).unwrap().contract_class(); +// let constructor_args = array![nft_address.into(),]; +// let (contract_address, _) = land_registry_contract.deploy(@constructor_args).unwrap(); +// contract_address // } -// // dummy test -// #[test] -// fn dummy_test() { -// assert(2 + 2 == 4, 'wrong answer'); -// } + From 98661c54e898d4e5d0e1ecd4b3be2788d9ba8fa1 Mon Sep 17 00:00:00 2001 From: Nenad Date: Fri, 25 Oct 2024 08:36:04 +0200 Subject: [PATCH 03/16] Add metadata URI tests --- land_registry/tests/test_land_nft.cairo | 54 +++++++++++++++++++++---- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/land_registry/tests/test_land_nft.cairo b/land_registry/tests/test_land_nft.cairo index 01685386..7827a68a 100644 --- a/land_registry/tests/test_land_nft.cairo +++ b/land_registry/tests/test_land_nft.cairo @@ -5,14 +5,52 @@ use land_registry::interface::{ }; use land_registry::land_register::LandRegistryContract; use land_registry::land_nft::{ILandNFTDispatcher, ILandNFTDispatcherTrait}; -// fn deploy(name: ByteArray) -> ContractAddress { -// let nft_contract = declare("LandNFT").unwrap().contract_class(); -// let (nft_address, _) = nft_contract.deploy(@array![Accounts::nft().into()]).unwrap(); -// let land_registry_contract = declare(name).unwrap().contract_class(); -// let constructor_args = array![nft_address.into(),]; -// let (contract_address, _) = land_registry_contract.deploy(@constructor_args).unwrap(); -// contract_address -// } +pub mod Accounts { + use starknet::{ContractAddress, contract_address_const}; + pub fn land_registry() -> ContractAddress { + contract_address_const::<'land_registry'>() + } +} + +fn deploy(metadata_uri: ByteArray) -> ILandNFTDispatcher { + let mut constructor_args: Array = array![]; + (Accounts::land_registry(), metadata_uri).serialize(ref constructor_args); + + let contract = declare("LandNFT").unwrap().contract_class(); + let (contract_address, _) = contract.deploy(@constructor_args).unwrap(); + + ILandNFTDispatcher { contract_address } +} + +#[test] +fn test_metadata_uri() { + let metadata_uri = "https://some.metadata.uri/nft_id"; + let dispatcher = deploy(metadata_uri); + + assert_eq!(metadata_uri, dispatcher.metadata_uri()); +} + +#[test] +fn test_update_metadata_uri() { + let original_metadata_uri = "https://original.metadata.uri/nft_id"; + let dispatcher = deploy(metadata_uri); + start_cheat_caller_address(dispatcher.contract_address, Accounts::land_registry()); + + let new_metadata_uri = "https://new.metadata.uri/nft_id"; + dispatcher.update_metadata_uri(new_metadata_uri); + + assert_eq!(new_metadata_uri, dispatcher.metadata_uri()); +} + +#[test] +#[should_panic(expected: ('Only land registry can update',))] +fn test_update_metadata_uri_from_non_land_registry() { + let original_metadata_uri = "https://original.metadata.uri/nft_id"; + let dispatcher = deploy(metadata_uri); + + let new_metadata_uri = "https://new.metadata.uri/nft_id"; + dispatcher.update_metadata_uri(new_metadata_uri); +} From 0f057509cb234af0779414ec9352709256c652ed Mon Sep 17 00:00:00 2001 From: Nenad Date: Fri, 25 Oct 2024 08:50:54 +0200 Subject: [PATCH 04/16] emit event on metadata URI updated --- land_registry/src/land_nft.cairo | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/land_registry/src/land_nft.cairo b/land_registry/src/land_nft.cairo index 5dfbb005..bcb3dcb0 100644 --- a/land_registry/src/land_nft.cairo +++ b/land_registry/src/land_nft.cairo @@ -47,6 +47,12 @@ pub mod LandNFT { ERC721Event: ERC721Component::Event, #[flat] SRC5Event: SRC5Component::Event, + MetadataURIUpdated: MetadataURIUpdated + } + + #[derive(Drop, starknet::Event)] + struct MetadataURIUpdated { + new_metadata_uri: ByteArray } #[constructor] @@ -86,7 +92,8 @@ pub mod LandNFT { starknet::get_caller_address() == self.land_registry.read(), 'Only land registry can update' ); - self.metadata_uri.write(new_metadata_uri); + self.metadata_uri.write(new_metadata_uri.clone()); + self.emit(MetadataURIUpdated { new_metadata_uri }); } fn metadata_uri(self: @ContractState) -> ByteArray { From 9a2b2b6fcb04e1972dd04c1e68dc0272dba7759a Mon Sep 17 00:00:00 2001 From: Nenad Date: Fri, 25 Oct 2024 08:51:26 +0200 Subject: [PATCH 05/16] add assert_macros dependency --- land_registry/Scarb.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/land_registry/Scarb.toml b/land_registry/Scarb.toml index 09052de4..f3272690 100644 --- a/land_registry/Scarb.toml +++ b/land_registry/Scarb.toml @@ -11,6 +11,7 @@ starknet = ">=2.8.0" openzeppelin = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag = "v0.16.0" } [dev-dependencies] +assert_macros = "2.8.2" snforge_std = { git = "https://github.com/foundry-rs/starknet-foundry", tag = "v0.31.0" } [[target.starknet-contract]] @@ -18,4 +19,4 @@ casm = true sierra = true [scripts] -test = "snforge test" \ No newline at end of file +test = "snforge test" From 80f6ebe8c844dac2ae85a0308f8d7a25dad2e499 Mon Sep 17 00:00:00 2001 From: Nenad Date: Fri, 25 Oct 2024 08:51:38 +0200 Subject: [PATCH 06/16] fix tests --- land_registry/tests/test_land_nft.cairo | 30 +++++++++++++++++-------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/land_registry/tests/test_land_nft.cairo b/land_registry/tests/test_land_nft.cairo index 7827a68a..cb17e710 100644 --- a/land_registry/tests/test_land_nft.cairo +++ b/land_registry/tests/test_land_nft.cairo @@ -1,10 +1,9 @@ use starknet::{ContractAddress, contract_address_const}; -use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, start_cheat_caller_address}; -use land_registry::interface::{ - ILandRegistryDispatcher, ILandRegistryDispatcherTrait, Land, LandUse +use snforge_std::{ + declare, ContractClassTrait, DeclareResultTrait, EventSpyAssertionsTrait, spy_events, + start_cheat_caller_address }; -use land_registry::land_register::LandRegistryContract; -use land_registry::land_nft::{ILandNFTDispatcher, ILandNFTDispatcherTrait}; +use land_registry::land_nft::{LandNFT, ILandNFTDispatcher, ILandNFTDispatcherTrait}; pub mod Accounts { use starknet::{ContractAddress, contract_address_const}; @@ -27,7 +26,7 @@ fn deploy(metadata_uri: ByteArray) -> ILandNFTDispatcher { #[test] fn test_metadata_uri() { let metadata_uri = "https://some.metadata.uri/nft_id"; - let dispatcher = deploy(metadata_uri); + let dispatcher = deploy(metadata_uri.clone()); assert_eq!(metadata_uri, dispatcher.metadata_uri()); } @@ -35,20 +34,33 @@ fn test_metadata_uri() { #[test] fn test_update_metadata_uri() { let original_metadata_uri = "https://original.metadata.uri/nft_id"; - let dispatcher = deploy(metadata_uri); + let dispatcher = deploy(original_metadata_uri.clone()); + let mut spy = spy_events(); start_cheat_caller_address(dispatcher.contract_address, Accounts::land_registry()); let new_metadata_uri = "https://new.metadata.uri/nft_id"; - dispatcher.update_metadata_uri(new_metadata_uri); + dispatcher.update_metadata_uri(new_metadata_uri.clone()); assert_eq!(new_metadata_uri, dispatcher.metadata_uri()); + + spy + .assert_emitted( + @array![ + ( + dispatcher.contract_address, + LandNFT::Event::MetadataURIUpdated( + LandNFT::MetadataURIUpdated { new_metadata_uri } + ) + ) + ] + ); } #[test] #[should_panic(expected: ('Only land registry can update',))] fn test_update_metadata_uri_from_non_land_registry() { let original_metadata_uri = "https://original.metadata.uri/nft_id"; - let dispatcher = deploy(metadata_uri); + let dispatcher = deploy(original_metadata_uri); let new_metadata_uri = "https://new.metadata.uri/nft_id"; dispatcher.update_metadata_uri(new_metadata_uri); From 52a97d50b63dc104e036dc500aefef643c5e8f36 Mon Sep 17 00:00:00 2001 From: Nenad Date: Fri, 25 Oct 2024 09:38:34 +0200 Subject: [PATCH 07/16] add locked status --- land_registry/src/land_nft.cairo | 45 ++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/land_registry/src/land_nft.cairo b/land_registry/src/land_nft.cairo index bcb3dcb0..b24554ae 100644 --- a/land_registry/src/land_nft.cairo +++ b/land_registry/src/land_nft.cairo @@ -10,13 +10,15 @@ pub trait ILandNFT { ); fn update_metadata_uri(ref self: TContractState, new_metadata_uri: ByteArray); fn metadata_uri(self: @TContractState) -> ByteArray; + fn lock(ref self: TContractState); + fn unlock(ref self: TContractState); } #[starknet::contract] pub mod LandNFT { use super::ILandNFT; use starknet::ContractAddress; - use openzeppelin::token::erc721::{ERC721Component, ERC721Component::InternalTrait}; + use openzeppelin::token::erc721::ERC721Component; use openzeppelin::introspection::src5::SRC5Component; use openzeppelin::token::erc721::ERC721HooksEmptyImpl; @@ -38,6 +40,7 @@ pub mod LandNFT { src5: SRC5Component::Storage, land_registry: ContractAddress, metadata_uri: ByteArray, + locked: bool, } #[event] @@ -47,7 +50,9 @@ pub mod LandNFT { ERC721Event: ERC721Component::Event, #[flat] SRC5Event: SRC5Component::Event, - MetadataURIUpdated: MetadataURIUpdated + MetadataURIUpdated: MetadataURIUpdated, + Locked: Locked, + Unlocked: Unlocked, } #[derive(Drop, starknet::Event)] @@ -55,6 +60,17 @@ pub mod LandNFT { new_metadata_uri: ByteArray } + #[derive(Drop, starknet::Event)] + pub struct Locked {} + + #[derive(Drop, starknet::Event)] + pub struct Unlocked {} + + pub mod Errors { + pub const LOCKED: felt252 = 'Locked'; + pub const NOT_LOCKED: felt252 = 'Not locked'; + } + #[constructor] fn constructor( ref self: ContractState, land_registry: ContractAddress, metadata_uri: ByteArray @@ -99,5 +115,30 @@ pub mod LandNFT { fn metadata_uri(self: @ContractState) -> ByteArray { self.metadata_uri.read() } + + fn lock(ref self: ContractState) { + self.assert_not_locked(); + self.locked.write(true); + self.emit(Locked {}); + } + + fn unlock(ref self: ContractState) { + self.assert_locked(); + self.locked.write(false); + self.emit(Unlocked {}); + } + } + + #[generate_trait] + impl Internal of InternalTrait { + /// Makes a function only callable when the contract is not locked. + fn assert_not_locked(self: @ContractState) { + assert(!self.locked.read(), Errors::LOCKED); + } + + /// Makes a function only callable when the contract is locked. + fn assert_locked(self: @ContractState) { + assert(self.locked.read(), Errors::NOT_LOCKED); + } } } From 133e3de3b221913ab2b8f66d2eb237bcef3d36f6 Mon Sep 17 00:00:00 2001 From: Nenad Date: Fri, 25 Oct 2024 10:15:59 +0200 Subject: [PATCH 08/16] add is_locked fn + only registry can lock checks --- land_registry/src/land_nft.cairo | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/land_registry/src/land_nft.cairo b/land_registry/src/land_nft.cairo index b24554ae..f568b030 100644 --- a/land_registry/src/land_nft.cairo +++ b/land_registry/src/land_nft.cairo @@ -12,6 +12,7 @@ pub trait ILandNFT { fn metadata_uri(self: @TContractState) -> ByteArray; fn lock(ref self: TContractState); fn unlock(ref self: TContractState); + fn is_locked(self: @TContractState) -> bool; } #[starknet::contract] @@ -99,6 +100,8 @@ pub mod LandNFT { starknet::get_caller_address() == self.land_registry.read(), 'Only land registry can transfer' ); + self.assert_not_locked(); + self.erc721.transfer(from, to, token_id); } @@ -117,16 +120,30 @@ pub mod LandNFT { } fn lock(ref self: ContractState) { + // Only land registry can lock + assert( + starknet::get_caller_address() == self.land_registry.read(), + 'Only land registry can lock' + ); self.assert_not_locked(); self.locked.write(true); self.emit(Locked {}); } fn unlock(ref self: ContractState) { + // Only land registry can unlock + assert( + starknet::get_caller_address() == self.land_registry.read(), + 'Only land registry can unlock' + ); self.assert_locked(); self.locked.write(false); self.emit(Unlocked {}); } + + fn is_locked(self: @ContractState) -> bool { + self.locked.read() + } } #[generate_trait] From 84465197298409f9b2d5defc4f8d2fcd89f75206 Mon Sep 17 00:00:00 2001 From: Nenad Date: Fri, 25 Oct 2024 10:16:04 +0200 Subject: [PATCH 09/16] Add tests for locked --- land_registry/tests/test_land_nft.cairo | 72 ++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/land_registry/tests/test_land_nft.cairo b/land_registry/tests/test_land_nft.cairo index cb17e710..133d0868 100644 --- a/land_registry/tests/test_land_nft.cairo +++ b/land_registry/tests/test_land_nft.cairo @@ -1,7 +1,7 @@ use starknet::{ContractAddress, contract_address_const}; use snforge_std::{ declare, ContractClassTrait, DeclareResultTrait, EventSpyAssertionsTrait, spy_events, - start_cheat_caller_address + start_cheat_caller_address, stop_cheat_caller_address }; use land_registry::land_nft::{LandNFT, ILandNFTDispatcher, ILandNFTDispatcherTrait}; @@ -66,3 +66,73 @@ fn test_update_metadata_uri_from_non_land_registry() { dispatcher.update_metadata_uri(new_metadata_uri); } + +#[test] +fn test_lock() { + let metadata_uri = "https://some.metadata.uri/nft_id"; + let dispatcher = deploy(metadata_uri); + let mut spy = spy_events(); + start_cheat_caller_address(dispatcher.contract_address, Accounts::land_registry()); + + // verify default state is unlocked + assert!(!dispatcher.is_locked()); + + dispatcher.lock(); + assert!(dispatcher.is_locked()); + + spy + .assert_emitted( + @array![(dispatcher.contract_address, LandNFT::Event::Locked(LandNFT::Locked {}))] + ); + + dispatcher.unlock(); + assert!(!dispatcher.is_locked()); + + spy + .assert_emitted( + @array![(dispatcher.contract_address, LandNFT::Event::Unlocked(LandNFT::Unlocked {}))] + ); +} + +#[test] +#[should_panic(expected: ('Only land registry can lock',))] +fn test_lock_from_non_land_registry() { + let metadata_uri = "https://some.metadata.uri/nft_id"; + let dispatcher = deploy(metadata_uri); + + dispatcher.lock(); +} + +#[test] +#[should_panic(expected: ('Locked',))] +fn test_lock_when_already_locked() { + let metadata_uri = "https://some.metadata.uri/nft_id"; + let dispatcher = deploy(metadata_uri); + start_cheat_caller_address(dispatcher.contract_address, Accounts::land_registry()); + + dispatcher.lock(); + dispatcher.lock(); +} + +#[test] +#[should_panic(expected: ('Only land registry can unlock',))] +fn test_unlock_from_non_land_registry() { + let metadata_uri = "https://some.metadata.uri/nft_id"; + let dispatcher = deploy(metadata_uri); + start_cheat_caller_address(dispatcher.contract_address, Accounts::land_registry()); + dispatcher.lock(); + stop_cheat_caller_address(dispatcher.contract_address); + + dispatcher.unlock(); +} + +#[test] +#[should_panic(expected: ('Not locked',))] +fn test_unlock_when_already_unlocked() { + let metadata_uri = "https://some.metadata.uri/nft_id"; + let dispatcher = deploy(metadata_uri); + start_cheat_caller_address(dispatcher.contract_address, Accounts::land_registry()); + + dispatcher.unlock(); +} + From 77a8bf7869f19ed8ff3a46ba911d52001cfd8a81 Mon Sep 17 00:00:00 2001 From: Nenad Date: Mon, 28 Oct 2024 12:11:01 +0100 Subject: [PATCH 10/16] add comment for ensure locked --- land_registry/tests/test_land_nft.cairo | 1 + 1 file changed, 1 insertion(+) diff --git a/land_registry/tests/test_land_nft.cairo b/land_registry/tests/test_land_nft.cairo index 133d0868..5c1c889c 100644 --- a/land_registry/tests/test_land_nft.cairo +++ b/land_registry/tests/test_land_nft.cairo @@ -119,6 +119,7 @@ fn test_lock_when_already_locked() { fn test_unlock_from_non_land_registry() { let metadata_uri = "https://some.metadata.uri/nft_id"; let dispatcher = deploy(metadata_uri); + // ensure state was 'locked' start_cheat_caller_address(dispatcher.contract_address, Accounts::land_registry()); dispatcher.lock(); stop_cheat_caller_address(dispatcher.contract_address); From bfc52fb352b4ca9d93b6ab0ae307ffedd1133c7e Mon Sep 17 00:00:00 2001 From: Nenad Date: Tue, 29 Oct 2024 09:14:41 +0100 Subject: [PATCH 11/16] Use ERC721's base_uri instead of metadata_uri --- land_registry/src/land_nft.cairo | 31 ++++------- land_registry/tests/test_land_nft.cairo | 74 ++++++++++++++----------- 2 files changed, 55 insertions(+), 50 deletions(-) diff --git a/land_registry/src/land_nft.cairo b/land_registry/src/land_nft.cairo index 5cc83cbe..9b0be95f 100644 --- a/land_registry/src/land_nft.cairo +++ b/land_registry/src/land_nft.cairo @@ -8,8 +8,7 @@ pub trait ILandNFT { fn transfer( ref self: TContractState, from: ContractAddress, to: ContractAddress, token_id: u256 ); - fn update_metadata_uri(ref self: TContractState, new_metadata_uri: ByteArray); - fn metadata_uri(self: @TContractState) -> ByteArray; + fn set_base_uri(ref self: TContractState, new_base_uri: ByteArray); fn lock(ref self: TContractState); fn unlock(ref self: TContractState); fn is_locked(self: @TContractState) -> bool; @@ -17,19 +16,21 @@ pub trait ILandNFT { #[starknet::contract] pub mod LandNFT { + use core::num::traits::Zero; use super::ILandNFT; use starknet::ContractAddress; use openzeppelin::token::erc721::ERC721Component; use openzeppelin::introspection::src5::SRC5Component; use openzeppelin::token::erc721::ERC721HooksEmptyImpl; - component!(path: ERC721Component, storage: erc721, event: ERC721Event); component!(path: SRC5Component, storage: src5, event: SRC5Event); #[abi(embed_v0)] impl ERC721Impl = ERC721Component::ERC721Impl; #[abi(embed_v0)] + impl ERC721MetadataImpl = ERC721Component::ERC721MetadataImpl; + #[abi(embed_v0)] impl ERC721CamelOnlyImpl = ERC721Component::ERC721CamelOnlyImpl; impl ERC721InternalImpl = ERC721Component::InternalImpl; @@ -40,7 +41,6 @@ pub mod LandNFT { #[substorage(v0)] src5: SRC5Component::Storage, land_registry: ContractAddress, - metadata_uri: ByteArray, locked: bool, } @@ -51,14 +51,14 @@ pub mod LandNFT { ERC721Event: ERC721Component::Event, #[flat] SRC5Event: SRC5Component::Event, - MetadataURIUpdated: MetadataURIUpdated, + BaseURIUpdated: BaseURIUpdated, Locked: Locked, Unlocked: Unlocked, } #[derive(Drop, starknet::Event)] - struct MetadataURIUpdated { - new_metadata_uri: ByteArray + struct BaseURIUpdated { + new_base_uri: ByteArray } #[derive(Drop, starknet::Event)] @@ -73,12 +73,9 @@ pub mod LandNFT { } #[constructor] - fn constructor( - ref self: ContractState, land_registry: ContractAddress, metadata_uri: ByteArray - ) { - self.erc721.initializer("Land NFT", "LAND", format!("")); + fn constructor(ref self: ContractState, land_registry: ContractAddress, base_uri: ByteArray) { + self.erc721.initializer("Land NFT", "LAND", base_uri); self.land_registry.write(land_registry); - self.metadata_uri.write(metadata_uri); } #[abi(embed_v0)] @@ -105,18 +102,14 @@ pub mod LandNFT { self.erc721.transfer(from, to, token_id); } - fn update_metadata_uri(ref self: ContractState, new_metadata_uri: ByteArray) { + fn set_base_uri(ref self: ContractState, new_base_uri: ByteArray) { // Only the land registry contract can update the metadata URI assert( starknet::get_caller_address() == self.land_registry.read(), 'Only land registry can update' ); - self.metadata_uri.write(new_metadata_uri.clone()); - self.emit(MetadataURIUpdated { new_metadata_uri }); - } - - fn metadata_uri(self: @ContractState) -> ByteArray { - self.metadata_uri.read() + self.erc721._set_base_uri(new_base_uri.clone()); + self.emit(BaseURIUpdated { new_base_uri }); } fn lock(ref self: ContractState) { diff --git a/land_registry/tests/test_land_nft.cairo b/land_registry/tests/test_land_nft.cairo index 5c1c889c..ca23a278 100644 --- a/land_registry/tests/test_land_nft.cairo +++ b/land_registry/tests/test_land_nft.cairo @@ -4,6 +4,9 @@ use snforge_std::{ start_cheat_caller_address, stop_cheat_caller_address }; use land_registry::land_nft::{LandNFT, ILandNFTDispatcher, ILandNFTDispatcherTrait}; +use openzeppelin::token::erc721::interface::{ + IERC721MetadataDispatcher, IERC721MetadataDispatcherTrait +}; pub mod Accounts { use starknet::{ContractAddress, contract_address_const}; @@ -11,46 +14,55 @@ pub mod Accounts { pub fn land_registry() -> ContractAddress { contract_address_const::<'land_registry'>() } + + pub fn land_owner() -> ContractAddress { + contract_address_const::<'land_owner'>() + } } -fn deploy(metadata_uri: ByteArray) -> ILandNFTDispatcher { +const TOKEN_ID: u256 = 1; + +fn deploy(base_uri: ByteArray) -> ILandNFTDispatcher { let mut constructor_args: Array = array![]; - (Accounts::land_registry(), metadata_uri).serialize(ref constructor_args); + (Accounts::land_registry(), base_uri).serialize(ref constructor_args); let contract = declare("LandNFT").unwrap().contract_class(); let (contract_address, _) = contract.deploy(@constructor_args).unwrap(); - ILandNFTDispatcher { contract_address } + let dispatcher = ILandNFTDispatcher { contract_address }; + dispatcher.mint(Accounts::land_owner(), TOKEN_ID); + + dispatcher } #[test] -fn test_metadata_uri() { - let metadata_uri = "https://some.metadata.uri/nft_id"; - let dispatcher = deploy(metadata_uri.clone()); +fn test_base_uri() { + let base_uri = "https://some.base.uri/"; + let dispatcher = deploy(base_uri.clone()); + let dispatcher = IERC721MetadataDispatcher { contract_address: dispatcher.contract_address }; - assert_eq!(metadata_uri, dispatcher.metadata_uri()); + assert_eq!(format!("{base_uri}{TOKEN_ID}"), dispatcher.token_uri(TOKEN_ID)); } #[test] -fn test_update_metadata_uri() { - let original_metadata_uri = "https://original.metadata.uri/nft_id"; - let dispatcher = deploy(original_metadata_uri.clone()); +fn test_set_base_uri() { + let original_base_uri = "https://original.base.uri/"; + let dispatcher = deploy(original_base_uri.clone()); let mut spy = spy_events(); start_cheat_caller_address(dispatcher.contract_address, Accounts::land_registry()); - let new_metadata_uri = "https://new.metadata.uri/nft_id"; - dispatcher.update_metadata_uri(new_metadata_uri.clone()); + let new_base_uri = "https://new.base.uri/"; + dispatcher.set_base_uri(new_base_uri.clone()); - assert_eq!(new_metadata_uri, dispatcher.metadata_uri()); + let dispatcher = IERC721MetadataDispatcher { contract_address: dispatcher.contract_address }; + assert_eq!(format!("{new_base_uri}{TOKEN_ID}"), dispatcher.token_uri(TOKEN_ID)); spy .assert_emitted( @array![ ( dispatcher.contract_address, - LandNFT::Event::MetadataURIUpdated( - LandNFT::MetadataURIUpdated { new_metadata_uri } - ) + LandNFT::Event::BaseURIUpdated(LandNFT::BaseURIUpdated { new_base_uri }) ) ] ); @@ -58,19 +70,19 @@ fn test_update_metadata_uri() { #[test] #[should_panic(expected: ('Only land registry can update',))] -fn test_update_metadata_uri_from_non_land_registry() { - let original_metadata_uri = "https://original.metadata.uri/nft_id"; - let dispatcher = deploy(original_metadata_uri); +fn test_set_base_uri_from_non_land_registry() { + let original_base_uri = "https://original.base.uri/"; + let dispatcher = deploy(original_base_uri); - let new_metadata_uri = "https://new.metadata.uri/nft_id"; - dispatcher.update_metadata_uri(new_metadata_uri); + let new_base_uri = "https://new.base.uri/"; + dispatcher.set_base_uri(new_base_uri); } #[test] fn test_lock() { - let metadata_uri = "https://some.metadata.uri/nft_id"; - let dispatcher = deploy(metadata_uri); + let base_uri = "https://some.base.uri/"; + let dispatcher = deploy(base_uri); let mut spy = spy_events(); start_cheat_caller_address(dispatcher.contract_address, Accounts::land_registry()); @@ -97,8 +109,8 @@ fn test_lock() { #[test] #[should_panic(expected: ('Only land registry can lock',))] fn test_lock_from_non_land_registry() { - let metadata_uri = "https://some.metadata.uri/nft_id"; - let dispatcher = deploy(metadata_uri); + let base_uri = "https://some.base.uri/"; + let dispatcher = deploy(base_uri); dispatcher.lock(); } @@ -106,8 +118,8 @@ fn test_lock_from_non_land_registry() { #[test] #[should_panic(expected: ('Locked',))] fn test_lock_when_already_locked() { - let metadata_uri = "https://some.metadata.uri/nft_id"; - let dispatcher = deploy(metadata_uri); + let base_uri = "https://some.base.uri/"; + let dispatcher = deploy(base_uri); start_cheat_caller_address(dispatcher.contract_address, Accounts::land_registry()); dispatcher.lock(); @@ -117,8 +129,8 @@ fn test_lock_when_already_locked() { #[test] #[should_panic(expected: ('Only land registry can unlock',))] fn test_unlock_from_non_land_registry() { - let metadata_uri = "https://some.metadata.uri/nft_id"; - let dispatcher = deploy(metadata_uri); + let base_uri = "https://some.base.uri/"; + let dispatcher = deploy(base_uri); // ensure state was 'locked' start_cheat_caller_address(dispatcher.contract_address, Accounts::land_registry()); dispatcher.lock(); @@ -130,8 +142,8 @@ fn test_unlock_from_non_land_registry() { #[test] #[should_panic(expected: ('Not locked',))] fn test_unlock_when_already_unlocked() { - let metadata_uri = "https://some.metadata.uri/nft_id"; - let dispatcher = deploy(metadata_uri); + let base_uri = "https://some.base.uri/"; + let dispatcher = deploy(base_uri); start_cheat_caller_address(dispatcher.contract_address, Accounts::land_registry()); dispatcher.unlock(); From a54d27c94e57a883beab5a556fc20dfbb1929b50 Mon Sep 17 00:00:00 2001 From: Nenad Date: Tue, 29 Oct 2024 09:21:44 +0100 Subject: [PATCH 12/16] add caller to set_base_uri --- land_registry/src/land_nft.cairo | 11 ++++++++--- land_registry/tests/test_land_nft.cairo | 23 ++++++++++++++++++++--- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/land_registry/src/land_nft.cairo b/land_registry/src/land_nft.cairo index 9b0be95f..4c18d313 100644 --- a/land_registry/src/land_nft.cairo +++ b/land_registry/src/land_nft.cairo @@ -8,7 +8,7 @@ pub trait ILandNFT { fn transfer( ref self: TContractState, from: ContractAddress, to: ContractAddress, token_id: u256 ); - fn set_base_uri(ref self: TContractState, new_base_uri: ByteArray); + fn set_base_uri(ref self: TContractState, new_base_uri: ByteArray, updater: ContractAddress); fn lock(ref self: TContractState); fn unlock(ref self: TContractState); fn is_locked(self: @TContractState) -> bool; @@ -58,6 +58,7 @@ pub mod LandNFT { #[derive(Drop, starknet::Event)] struct BaseURIUpdated { + caller: ContractAddress, new_base_uri: ByteArray } @@ -68,6 +69,7 @@ pub mod LandNFT { pub struct Unlocked {} pub mod Errors { + pub const INVALID_ADDRESS: felt252 = 'Invalid address'; pub const LOCKED: felt252 = 'Locked'; pub const NOT_LOCKED: felt252 = 'Not locked'; } @@ -102,14 +104,17 @@ pub mod LandNFT { self.erc721.transfer(from, to, token_id); } - fn set_base_uri(ref self: ContractState, new_base_uri: ByteArray) { + fn set_base_uri( + ref self: ContractState, new_base_uri: ByteArray, updater: ContractAddress + ) { // Only the land registry contract can update the metadata URI assert( starknet::get_caller_address() == self.land_registry.read(), 'Only land registry can update' ); + assert(Zero::is_non_zero(@updater), Errors::INVALID_ADDRESS); self.erc721._set_base_uri(new_base_uri.clone()); - self.emit(BaseURIUpdated { new_base_uri }); + self.emit(BaseURIUpdated { caller: updater, new_base_uri }); } fn lock(ref self: ContractState) { diff --git a/land_registry/tests/test_land_nft.cairo b/land_registry/tests/test_land_nft.cairo index ca23a278..4b772203 100644 --- a/land_registry/tests/test_land_nft.cairo +++ b/land_registry/tests/test_land_nft.cairo @@ -9,6 +9,7 @@ use openzeppelin::token::erc721::interface::{ }; pub mod Accounts { + use core::num::traits::Zero; use starknet::{ContractAddress, contract_address_const}; pub fn land_registry() -> ContractAddress { @@ -18,6 +19,10 @@ pub mod Accounts { pub fn land_owner() -> ContractAddress { contract_address_const::<'land_owner'>() } + + pub fn zero() -> ContractAddress { + Zero::zero() + } } const TOKEN_ID: u256 = 1; @@ -52,7 +57,7 @@ fn test_set_base_uri() { start_cheat_caller_address(dispatcher.contract_address, Accounts::land_registry()); let new_base_uri = "https://new.base.uri/"; - dispatcher.set_base_uri(new_base_uri.clone()); + dispatcher.set_base_uri(new_base_uri.clone(), Accounts::land_owner()); let dispatcher = IERC721MetadataDispatcher { contract_address: dispatcher.contract_address }; assert_eq!(format!("{new_base_uri}{TOKEN_ID}"), dispatcher.token_uri(TOKEN_ID)); @@ -62,7 +67,9 @@ fn test_set_base_uri() { @array![ ( dispatcher.contract_address, - LandNFT::Event::BaseURIUpdated(LandNFT::BaseURIUpdated { new_base_uri }) + LandNFT::Event::BaseURIUpdated( + LandNFT::BaseURIUpdated { caller: Accounts::land_owner(), new_base_uri } + ) ) ] ); @@ -75,9 +82,19 @@ fn test_set_base_uri_from_non_land_registry() { let dispatcher = deploy(original_base_uri); let new_base_uri = "https://new.base.uri/"; - dispatcher.set_base_uri(new_base_uri); + dispatcher.set_base_uri(new_base_uri, Accounts::land_owner()); } +#[test] +#[should_panic(expected: ('Invalid address',))] +fn test_set_base_uri_updated_zero_address() { + let original_base_uri = "https://original.base.uri/"; + let dispatcher = deploy(original_base_uri); + start_cheat_caller_address(dispatcher.contract_address, Accounts::land_registry()); + + let new_base_uri = "https://new.base.uri/"; + dispatcher.set_base_uri(new_base_uri, Accounts::zero()); +} #[test] fn test_lock() { From dda332893d161db341ab2cfbebffa9764f883a75 Mon Sep 17 00:00:00 2001 From: Nenad Date: Tue, 29 Oct 2024 09:33:04 +0100 Subject: [PATCH 13/16] Lock should refer to specific token --- land_registry/src/land_nft.cairo | 53 ++++++++++++++--------- land_registry/tests/test_land_nft.cairo | 57 +++++++++++++++++++------ 2 files changed, 76 insertions(+), 34 deletions(-) diff --git a/land_registry/src/land_nft.cairo b/land_registry/src/land_nft.cairo index 4c18d313..aa5ef44c 100644 --- a/land_registry/src/land_nft.cairo +++ b/land_registry/src/land_nft.cairo @@ -9,9 +9,9 @@ pub trait ILandNFT { ref self: TContractState, from: ContractAddress, to: ContractAddress, token_id: u256 ); fn set_base_uri(ref self: TContractState, new_base_uri: ByteArray, updater: ContractAddress); - fn lock(ref self: TContractState); - fn unlock(ref self: TContractState); - fn is_locked(self: @TContractState) -> bool; + fn lock(ref self: TContractState, token_id: u256); + fn unlock(ref self: TContractState, token_id: u256); + fn is_locked(self: @TContractState, token_id: u256) -> bool; } #[starknet::contract] @@ -19,6 +19,9 @@ pub mod LandNFT { use core::num::traits::Zero; use super::ILandNFT; use starknet::ContractAddress; + use starknet::storage::{ + StoragePointerReadAccess, StoragePointerWriteAccess, StoragePathEntry, Map + }; use openzeppelin::token::erc721::ERC721Component; use openzeppelin::introspection::src5::SRC5Component; use openzeppelin::token::erc721::ERC721HooksEmptyImpl; @@ -41,7 +44,7 @@ pub mod LandNFT { #[substorage(v0)] src5: SRC5Component::Storage, land_registry: ContractAddress, - locked: bool, + locked: Map, } #[event] @@ -63,10 +66,14 @@ pub mod LandNFT { } #[derive(Drop, starknet::Event)] - pub struct Locked {} + pub struct Locked { + token_id: u256 + } #[derive(Drop, starknet::Event)] - pub struct Unlocked {} + pub struct Unlocked { + token_id: u256 + } pub mod Errors { pub const INVALID_ADDRESS: felt252 = 'Invalid address'; @@ -99,8 +106,10 @@ pub mod LandNFT { starknet::get_caller_address() == self.land_registry.read(), 'Only land registry can transfer' ); - self.assert_not_locked(); + self._assert_not_locked(token_id); + // ERC721::transfer ensures the token already existed and that + // from was really its previous owner self.erc721.transfer(from, to, token_id); } @@ -117,43 +126,45 @@ pub mod LandNFT { self.emit(BaseURIUpdated { caller: updater, new_base_uri }); } - fn lock(ref self: ContractState) { + fn lock(ref self: ContractState, token_id: u256) { // Only land registry can lock assert( starknet::get_caller_address() == self.land_registry.read(), 'Only land registry can lock' ); - self.assert_not_locked(); - self.locked.write(true); - self.emit(Locked {}); + self.erc721._require_owned(token_id); + self._assert_not_locked(token_id); + self.locked.entry(token_id).write(true); + self.emit(Locked { token_id }); } - fn unlock(ref self: ContractState) { + fn unlock(ref self: ContractState, token_id: u256) { // Only land registry can unlock assert( starknet::get_caller_address() == self.land_registry.read(), 'Only land registry can unlock' ); - self.assert_locked(); - self.locked.write(false); - self.emit(Unlocked {}); + self.erc721._require_owned(token_id); + self._assert_locked(token_id); + self.locked.entry(token_id).write(false); + self.emit(Unlocked { token_id }); } - fn is_locked(self: @ContractState) -> bool { - self.locked.read() + fn is_locked(self: @ContractState, token_id: u256) -> bool { + self.locked.entry(token_id).read() } } #[generate_trait] impl Internal of InternalTrait { /// Makes a function only callable when the contract is not locked. - fn assert_not_locked(self: @ContractState) { - assert(!self.locked.read(), Errors::LOCKED); + fn _assert_not_locked(self: @ContractState, token_id: u256) { + assert(!self.is_locked(token_id), Errors::LOCKED); } /// Makes a function only callable when the contract is locked. - fn assert_locked(self: @ContractState) { - assert(self.locked.read(), Errors::NOT_LOCKED); + fn _assert_locked(self: @ContractState, token_id: u256) { + assert(self.is_locked(token_id), Errors::NOT_LOCKED); } } } diff --git a/land_registry/tests/test_land_nft.cairo b/land_registry/tests/test_land_nft.cairo index 4b772203..ffd9e555 100644 --- a/land_registry/tests/test_land_nft.cairo +++ b/land_registry/tests/test_land_nft.cairo @@ -26,6 +26,7 @@ pub mod Accounts { } const TOKEN_ID: u256 = 1; +const NON_EXISTENT_TOKEN_ID: u256 = 2; fn deploy(base_uri: ByteArray) -> ILandNFTDispatcher { let mut constructor_args: Array = array![]; @@ -104,22 +105,32 @@ fn test_lock() { start_cheat_caller_address(dispatcher.contract_address, Accounts::land_registry()); // verify default state is unlocked - assert!(!dispatcher.is_locked()); + assert!(!dispatcher.is_locked(TOKEN_ID)); - dispatcher.lock(); - assert!(dispatcher.is_locked()); + dispatcher.lock(TOKEN_ID); + assert!(dispatcher.is_locked(TOKEN_ID)); spy .assert_emitted( - @array![(dispatcher.contract_address, LandNFT::Event::Locked(LandNFT::Locked {}))] + @array![ + ( + dispatcher.contract_address, + LandNFT::Event::Locked(LandNFT::Locked { token_id: TOKEN_ID }) + ) + ] ); - dispatcher.unlock(); - assert!(!dispatcher.is_locked()); + dispatcher.unlock(TOKEN_ID); + assert!(!dispatcher.is_locked(TOKEN_ID)); spy .assert_emitted( - @array![(dispatcher.contract_address, LandNFT::Event::Unlocked(LandNFT::Unlocked {}))] + @array![ + ( + dispatcher.contract_address, + LandNFT::Event::Unlocked(LandNFT::Unlocked { token_id: TOKEN_ID }) + ) + ] ); } @@ -129,7 +140,7 @@ fn test_lock_from_non_land_registry() { let base_uri = "https://some.base.uri/"; let dispatcher = deploy(base_uri); - dispatcher.lock(); + dispatcher.lock(TOKEN_ID); } #[test] @@ -139,8 +150,18 @@ fn test_lock_when_already_locked() { let dispatcher = deploy(base_uri); start_cheat_caller_address(dispatcher.contract_address, Accounts::land_registry()); - dispatcher.lock(); - dispatcher.lock(); + dispatcher.lock(TOKEN_ID); + dispatcher.lock(TOKEN_ID); +} + +#[test] +#[should_panic(expected: ('ERC721: invalid token ID',))] +fn test_lock_non_existing_token() { + let base_uri = "https://some.base.uri/"; + let dispatcher = deploy(base_uri); + start_cheat_caller_address(dispatcher.contract_address, Accounts::land_registry()); + + dispatcher.lock(NON_EXISTENT_TOKEN_ID); } #[test] @@ -150,10 +171,10 @@ fn test_unlock_from_non_land_registry() { let dispatcher = deploy(base_uri); // ensure state was 'locked' start_cheat_caller_address(dispatcher.contract_address, Accounts::land_registry()); - dispatcher.lock(); + dispatcher.lock(TOKEN_ID); stop_cheat_caller_address(dispatcher.contract_address); - dispatcher.unlock(); + dispatcher.unlock(TOKEN_ID); } #[test] @@ -163,6 +184,16 @@ fn test_unlock_when_already_unlocked() { let dispatcher = deploy(base_uri); start_cheat_caller_address(dispatcher.contract_address, Accounts::land_registry()); - dispatcher.unlock(); + dispatcher.unlock(TOKEN_ID); +} + +#[test] +#[should_panic(expected: ('ERC721: invalid token ID',))] +fn test_unlock_non_existing_token() { + let base_uri = "https://some.base.uri/"; + let dispatcher = deploy(base_uri); + start_cheat_caller_address(dispatcher.contract_address, Accounts::land_registry()); + + dispatcher.unlock(NON_EXISTENT_TOKEN_ID); } From 9bb5de1cf3b10447993a746faab0bedd2e12bb2c Mon Sep 17 00:00:00 2001 From: Nenad Date: Tue, 12 Nov 2024 10:40:07 +0100 Subject: [PATCH 14/16] Fix test_land_registerc --- land_registry/tests/test_land_register.cairo | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/land_registry/tests/test_land_register.cairo b/land_registry/tests/test_land_register.cairo index 495f68c5..8aa916f8 100644 --- a/land_registry/tests/test_land_register.cairo +++ b/land_registry/tests/test_land_register.cairo @@ -25,8 +25,11 @@ pub mod Accounts { fn deploy(name: ByteArray) -> ContractAddress { // Deploy Ownable contract + let base_uri: ByteArray = "https://example.base.uri/"; + let mut constructor_args: Array = array![]; + (Accounts::nft(), base_uri).serialize(ref constructor_args); let nft_contract = declare("LandNFT").unwrap().contract_class(); - let (nft_address, _) = nft_contract.deploy(@array![Accounts::nft().into()]).unwrap(); + let (nft_address, _) = nft_contract.deploy(@constructor_args).unwrap(); // Deploy Aggregator contract let land_registry_contract = declare(name).unwrap().contract_class(); From 124cbb17d9b94bef6d1744672026496ae20f2961 Mon Sep 17 00:00:00 2001 From: Nenad Date: Tue, 12 Nov 2024 10:40:35 +0100 Subject: [PATCH 15/16] Fix custom error path in land_nft --- land_registry/.tool-versions | 1 + land_registry/src/land_nft.cairo | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 land_registry/.tool-versions diff --git a/land_registry/.tool-versions b/land_registry/.tool-versions new file mode 100644 index 00000000..cc60fd62 --- /dev/null +++ b/land_registry/.tool-versions @@ -0,0 +1 @@ +scarb 2.8.2 diff --git a/land_registry/src/land_nft.cairo b/land_registry/src/land_nft.cairo index 2360ca7a..086e3abc 100644 --- a/land_registry/src/land_nft.cairo +++ b/land_registry/src/land_nft.cairo @@ -26,7 +26,7 @@ pub mod LandNFT { use openzeppelin::token::erc721::ERC721Component; use openzeppelin::introspection::src5::SRC5Component; use openzeppelin::token::erc721::ERC721HooksEmptyImpl; - use land_registry::custom_error::Errors; + use land_registry::custom_error; component!(path: ERC721Component, storage: erc721, event: ERC721Event); component!(path: SRC5Component, storage: src5, event: SRC5Event); @@ -105,7 +105,8 @@ pub mod LandNFT { ) { // Only the land registry contract can transfer NFTs assert( - starknet::get_caller_address() == self.land_registry.read(), Errors::TRANSFER_NFT + starknet::get_caller_address() == self.land_registry.read(), + custom_error::Errors::TRANSFER_NFT ); self._assert_not_locked(token_id); From 1e4506a6a8deb721bd16d0e8c662a1693348222d Mon Sep 17 00:00:00 2001 From: Nenad Date: Tue, 12 Nov 2024 10:40:57 +0100 Subject: [PATCH 16/16] remove tool-versions --- land_registry/.tool-versions | 1 - 1 file changed, 1 deletion(-) delete mode 100644 land_registry/.tool-versions diff --git a/land_registry/.tool-versions b/land_registry/.tool-versions deleted file mode 100644 index cc60fd62..00000000 --- a/land_registry/.tool-versions +++ /dev/null @@ -1 +0,0 @@ -scarb 2.8.2