Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Metadata URI for Land NFTs and locking mechanism for land transfers #109

Merged
merged 18 commits into from
Nov 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion land_registry/Scarb.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ casm = true
sierra = true

[scripts]
test = "snforge test"
test = "snforge test"
106 changes: 100 additions & 6 deletions land_registry/src/land_nft.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,33 @@ pub trait ILandNFT<TContractState> {
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);

#[abi(embed_v0)]
impl ERC721Impl = ERC721Component::ERC721Impl<ContractState>;
#[abi(embed_v0)]
impl ERC721MetadataImpl = ERC721Component::ERC721MetadataImpl<ContractState>;
#[abi(embed_v0)]
impl ERC721CamelOnlyImpl = ERC721Component::ERC721CamelOnlyImpl<ContractState>;
impl ERC721InternalImpl = ERC721Component::InternalImpl<ContractState>;

Expand All @@ -37,6 +46,7 @@ pub mod LandNFT {
#[substorage(v0)]
src5: SRC5Component::Storage,
land_registry: ContractAddress,
locked: Map<u256, bool>,
}

#[event]
Expand All @@ -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);
}

Expand All @@ -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);
}
}
}
223 changes: 199 additions & 24 deletions land_registry/tests/test_land_nft.cairo
Original file line number Diff line number Diff line change
@@ -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<felt252> = 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);
}

5 changes: 4 additions & 1 deletion land_registry/tests/test_land_register.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -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<felt252> = 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();
Expand Down
Loading