diff --git a/land_registry/Scarb.toml b/land_registry/Scarb.toml index 6ad56e27..f3272690 100644 --- a/land_registry/Scarb.toml +++ b/land_registry/Scarb.toml @@ -19,4 +19,4 @@ casm = true sierra = true [scripts] -test = "snforge test" \ No newline at end of file +test = "snforge test" diff --git a/land_registry/src/land_nft.cairo b/land_registry/src/land_nft.cairo index 8c2de833..086e3abc 100644 --- a/land_registry/src/land_nft.cairo +++ b/land_registry/src/land_nft.cairo @@ -9,17 +9,24 @@ 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, updater: ContractAddress); + 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] pub mod LandNFT { + use core::num::traits::Zero; use super::ILandNFT; use starknet::ContractAddress; - use openzeppelin::token::erc721::{ERC721Component, ERC721Component::InternalTrait}; + use starknet::storage::{ + StoragePointerReadAccess, StoragePointerWriteAccess, StoragePathEntry, Map + }; + 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); @@ -27,6 +34,8 @@ pub mod LandNFT { #[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; @@ -37,6 +46,7 @@ pub mod LandNFT { #[substorage(v0)] src5: SRC5Component::Storage, land_registry: ContractAddress, + locked: Map, } #[event] @@ -46,11 +56,36 @@ pub mod LandNFT { ERC721Event: ERC721Component::Event, #[flat] SRC5Event: SRC5Component::Event, + BaseURIUpdated: BaseURIUpdated, + Locked: Locked, + Unlocked: Unlocked, + } + + #[derive(Drop, starknet::Event)] + struct BaseURIUpdated { + caller: ContractAddress, + new_base_uri: ByteArray + } + + #[derive(Drop, starknet::Event)] + pub struct Locked { + token_id: u256 + } + + #[derive(Drop, starknet::Event)] + pub struct Unlocked { + token_id: u256 + } + + pub mod Errors { + pub const INVALID_ADDRESS: felt252 = 'Invalid address'; + pub const LOCKED: felt252 = 'Locked'; + pub const NOT_LOCKED: felt252 = 'Not locked'; } #[constructor] - fn constructor(ref self: ContractState, land_registry: ContractAddress) { - 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); } @@ -70,9 +105,68 @@ 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); + + // ERC721::transfer ensures the token already existed and that + // from was really its previous owner self.erc721.transfer(from, to, token_id); } + + 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 { caller: updater, new_base_uri }); + } + + 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.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, token_id: u256) { + // Only land registry can unlock + assert( + starknet::get_caller_address() == self.land_registry.read(), + 'Only land registry can unlock' + ); + 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, 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, 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, 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 c25342c6..ffd9e555 100644 --- a/land_registry/tests/test_land_nft.cairo +++ b/land_registry/tests/test_land_nft.cairo @@ -1,24 +1,199 @@ -// ************************************************************************* -// 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}; - -// // 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 } -// } -// } - -// // dummy test -// #[test] -// fn dummy_test() { -// assert(2 + 2 == 4, 'wrong answer'); -// } +use starknet::{ContractAddress, contract_address_const}; +use snforge_std::{ + declare, ContractClassTrait, DeclareResultTrait, EventSpyAssertionsTrait, spy_events, + 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 core::num::traits::Zero; + use starknet::{ContractAddress, contract_address_const}; + + pub fn land_registry() -> ContractAddress { + contract_address_const::<'land_registry'>() + } + + pub fn land_owner() -> ContractAddress { + contract_address_const::<'land_owner'>() + } + + pub fn zero() -> ContractAddress { + Zero::zero() + } +} + +const TOKEN_ID: u256 = 1; +const NON_EXISTENT_TOKEN_ID: u256 = 2; + +fn deploy(base_uri: ByteArray) -> ILandNFTDispatcher { + let mut constructor_args: Array = array![]; + (Accounts::land_registry(), base_uri).serialize(ref constructor_args); + + let contract = declare("LandNFT").unwrap().contract_class(); + let (contract_address, _) = contract.deploy(@constructor_args).unwrap(); + + let dispatcher = ILandNFTDispatcher { contract_address }; + dispatcher.mint(Accounts::land_owner(), TOKEN_ID); + + dispatcher +} + +#[test] +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!(format!("{base_uri}{TOKEN_ID}"), dispatcher.token_uri(TOKEN_ID)); +} + +#[test] +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_base_uri = "https://new.base.uri/"; + 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)); + + spy + .assert_emitted( + @array![ + ( + dispatcher.contract_address, + LandNFT::Event::BaseURIUpdated( + LandNFT::BaseURIUpdated { caller: Accounts::land_owner(), new_base_uri } + ) + ) + ] + ); +} + +#[test] +#[should_panic(expected: ('Only land registry can update',))] +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_base_uri = "https://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() { + 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()); + + // verify default state is unlocked + assert!(!dispatcher.is_locked(TOKEN_ID)); + + dispatcher.lock(TOKEN_ID); + assert!(dispatcher.is_locked(TOKEN_ID)); + + spy + .assert_emitted( + @array![ + ( + dispatcher.contract_address, + LandNFT::Event::Locked(LandNFT::Locked { token_id: TOKEN_ID }) + ) + ] + ); + + dispatcher.unlock(TOKEN_ID); + assert!(!dispatcher.is_locked(TOKEN_ID)); + + spy + .assert_emitted( + @array![ + ( + dispatcher.contract_address, + LandNFT::Event::Unlocked(LandNFT::Unlocked { token_id: TOKEN_ID }) + ) + ] + ); +} + +#[test] +#[should_panic(expected: ('Only land registry can lock',))] +fn test_lock_from_non_land_registry() { + let base_uri = "https://some.base.uri/"; + let dispatcher = deploy(base_uri); + + dispatcher.lock(TOKEN_ID); +} + +#[test] +#[should_panic(expected: ('Locked',))] +fn test_lock_when_already_locked() { + let base_uri = "https://some.base.uri/"; + let dispatcher = deploy(base_uri); + start_cheat_caller_address(dispatcher.contract_address, Accounts::land_registry()); + + 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] +#[should_panic(expected: ('Only land registry can unlock',))] +fn test_unlock_from_non_land_registry() { + 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(TOKEN_ID); + stop_cheat_caller_address(dispatcher.contract_address); + + dispatcher.unlock(TOKEN_ID); +} + +#[test] +#[should_panic(expected: ('Not locked',))] +fn test_unlock_when_already_unlocked() { + let base_uri = "https://some.base.uri/"; + let dispatcher = deploy(base_uri); + start_cheat_caller_address(dispatcher.contract_address, Accounts::land_registry()); + + 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); +} + 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();