diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 8b565898..00000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore index 98f6e300..db82c1e0 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,5 @@ examples/.env.testnet examples/.env.integration modules/.builder_casper modules/wasm -benchmark/gas_report.json \ No newline at end of file +benchmark/gas_report.json +.DS_Store \ No newline at end of file diff --git a/benchmark/src/storage.rs b/benchmark/src/storage.rs index c06285ee..41b52926 100644 --- a/benchmark/src/storage.rs +++ b/benchmark/src/storage.rs @@ -38,13 +38,13 @@ impl DictionaryStorage { /// Sets the value. pub fn set(&self, key: String, value: U256) { self.env() - .set_dictionary_value(DICT_KEY, self.key(key), value); + .set_dictionary_value(DICT_KEY, self.key(key).as_bytes(), value); } /// Gets the value. pub fn get_or_default(&self, key: String) -> U256 { self.env() - .get_dictionary_value(DICT_KEY, self.key(key)) + .get_dictionary_value(DICT_KEY, self.key(key).as_bytes()) .unwrap_or_default() } diff --git a/core/src/args.rs b/core/src/args.rs index a62d2222..d83f8176 100644 --- a/core/src/args.rs +++ b/core/src/args.rs @@ -7,11 +7,12 @@ use casper_types::{ }; /// A type that represents an entrypoint arg that may or may not be present. -#[derive(Debug, Clone)] +#[derive(Default, Debug, Clone)] pub enum Maybe { /// A value is present. Some(T), /// No value is present. + #[default] None } @@ -34,6 +35,14 @@ impl Maybe { Maybe::None => env.revert(ExecutionError::UnwrapError) } } + + /// Unwraps the value or returns the default value. + pub fn unwrap_or(self, default: T) -> T { + match self { + Maybe::Some(value) => value, + Maybe::None => default + } + } } impl Maybe { diff --git a/core/src/contract_container.rs b/core/src/contract_container.rs index a0a6d1bb..ed70bf44 100644 --- a/core/src/contract_container.rs +++ b/core/src/contract_container.rs @@ -1,8 +1,7 @@ -use crate::entry_point_callback::{Argument, EntryPointsCaller}; -use crate::{prelude::*, ExecutionError, OdraResult}; +use crate::entry_point_callback::EntryPointsCaller; +use crate::{prelude::*, OdraResult}; use crate::{CallDef, OdraError, VmError}; use casper_types::bytesrepr::Bytes; -use casper_types::RuntimeArgs; /// A wrapper struct for a EntryPointsCaller that is a layer of abstraction between the host and the entry points caller. /// @@ -23,56 +22,25 @@ impl ContractContainer { /// Calls the entry point with the given call definition. pub fn call(&self, call_def: CallDef) -> OdraResult { // find the entry point - let ep = self - .entry_points_caller + self.entry_points_caller .entry_points() .iter() .find(|ep| ep.name == call_def.entry_point()) .ok_or_else(|| { - OdraError::VmError(VmError::NoSuchMethod(call_def.entry_point().to_string())) + OdraError::VmError(VmError::NoSuchMethod(call_def.entry_point().to_owned())) })?; - // validate the args, return an error if the args are invalid - self.validate_args(&ep.args, call_def.args())?; self.entry_points_caller.call(call_def) } - - fn validate_args(&self, args: &[Argument], input_args: &RuntimeArgs) -> OdraResult<()> { - for arg in args { - // check if the input args contain the arg - if let Some(input) = input_args - .named_args() - .find(|input| input.name() == arg.name.as_str()) - { - // check if the input arg has the expected type - let input_ty = input.cl_value().cl_type(); - let expected_ty = &arg.ty; - if input_ty != expected_ty { - return Err(OdraError::VmError(VmError::TypeMismatch { - expected: expected_ty.clone(), - found: input_ty.clone() - })); - } - } else { - return Err(OdraError::ExecutionError(ExecutionError::MissingArg)); - } - } - Ok(()) - } } #[cfg(test)] mod tests { - use casper_types::CLType; - use super::ContractContainer; use crate::contract_context::MockContractContext; use crate::entry_point_callback::{Argument, EntryPoint, EntryPointsCaller}; use crate::host::{HostEnv, MockHostContext}; - use crate::{ - casper_types::{runtime_args, RuntimeArgs}, - OdraError, VmError - }; - use crate::{prelude::*, CallDef, ContractEnv, ExecutionError}; + use crate::{casper_types::RuntimeArgs, OdraError, VmError}; + use crate::{prelude::*, CallDef, ContractEnv}; const TEST_ENTRYPOINT: &str = "ep"; @@ -102,73 +70,6 @@ mod tests { assert!(result.is_ok()); } - #[test] - fn test_call_valid_entrypoint_with_wrong_arg_name() { - // Given an instance with a single entrypoint with one arg named "first". - let instance = ContractContainer::with_entrypoint(vec!["first"]); - - // When call the registered entrypoint with an arg named "second". - let call_def = CallDef::new(TEST_ENTRYPOINT, false, runtime_args! { "second" => 0u32 }); - let result = instance.call(call_def); - - // Then MissingArg error is returned. - assert_eq!( - result.unwrap_err(), - OdraError::ExecutionError(ExecutionError::MissingArg) - ); - } - - #[test] - fn test_call_valid_entrypoint_with_wrong_arg_type() { - // Given an instance with a single entrypoint with one arg named "first". - let instance = ContractContainer::with_entrypoint(vec!["first"]); - - // When call the registered entrypoint with an arg named "second". - let call_def = CallDef::new(TEST_ENTRYPOINT, false, runtime_args! { "first" => true }); - let result = instance.call(call_def); - - // Then MissingArg error is returned. - assert_eq!( - result.unwrap_err(), - OdraError::VmError(VmError::TypeMismatch { - expected: CLType::U32, - found: CLType::Bool - }) - ); - } - - #[test] - fn test_call_valid_entrypoint_with_missing_arg() { - // Given an instance with a single entrypoint with one arg named "first". - let instance = ContractContainer::with_entrypoint(vec!["first"]); - - // When call a valid entrypoint without args. - let call_def = CallDef::new(TEST_ENTRYPOINT, false, RuntimeArgs::new()); - let result = instance.call(call_def); - - // Then MissingArg error is returned. - assert_eq!( - result.unwrap_err(), - OdraError::ExecutionError(ExecutionError::MissingArg) - ); - } - - #[test] - fn test_many_missing_args() { - // Given an instance with a single entrypoint with "first", "second" and "third" args. - let instance = ContractContainer::with_entrypoint(vec!["first", "second", "third"]); - - // When call a valid entrypoint with a single valid args, - let call_def = CallDef::new(TEST_ENTRYPOINT, false, runtime_args! { "third" => 0u32 }); - let result = instance.call(call_def); - - // Then MissingArg error is returned. - assert_eq!( - result.unwrap_err(), - OdraError::ExecutionError(ExecutionError::MissingArg) - ); - } - impl ContractContainer { fn empty() -> Self { let ctx = Rc::new(RefCell::new(MockHostContext::new())); diff --git a/core/src/contract_context.rs b/core/src/contract_context.rs index f1b7166d..3bf42c86 100644 --- a/core/src/contract_context.rs +++ b/core/src/contract_context.rs @@ -49,7 +49,7 @@ pub trait ContractContext { /// /// * `dictionary_name` - The name of the dictionary. /// * `key` - The key to retrieve the value for. - fn get_dictionary_value(&self, dictionary_name: &str, key: &str) -> Option; + fn get_dictionary_value(&self, dictionary_name: &str, key: &[u8]) -> Option; /// Sets the key value behind a named dictionary. /// @@ -58,7 +58,13 @@ pub trait ContractContext { /// * `dictionary_name` - The name of the dictionary. /// * `key` - The key to set the value for. /// * `value` - The value to set. - fn set_dictionary_value(&self, dictionary_name: &str, key: &str, value: CLValue); + fn set_dictionary_value(&self, dictionary_name: &str, key: &[u8], value: CLValue); + + /// Removes the named key from the storage. + /// + /// # Arguments + /// * `dictionary_name` - The name of the dictionary. + fn remove_dictionary(&self, dictionary_name: &str); /// Retrieves the address of the caller. fn caller(&self) -> Address; diff --git a/core/src/contract_env.rs b/core/src/contract_env.rs index cd1178d8..00b4dc23 100644 --- a/core/src/contract_env.rs +++ b/core/src/contract_env.rs @@ -104,13 +104,12 @@ impl ContractEnv { } /// Retrieves the value associated with the given named key from the named dictionary in the contract storage. - pub fn get_dictionary_value, V: AsRef>( + pub fn get_dictionary_value>( &self, dictionary_name: U, - key: V + key: &[u8] ) -> Option { let dictionary_name = dictionary_name.as_ref(); - let key = key.as_ref(); let bytes = self .backend .borrow() @@ -123,14 +122,13 @@ impl ContractEnv { } /// Sets the value associated with the given named key in the named dictionary in the contract storage. - pub fn set_dictionary_value, V: AsRef>( + pub fn set_dictionary_value>( &self, dictionary_name: U, - key: V, + key: &[u8], value: T ) { let dictionary_name = dictionary_name.as_ref(); - let key = key.as_ref(); let cl_value = CLValue::from_t(value) .map_err(|_| Formatting) .unwrap_or_revert(self); @@ -139,6 +137,12 @@ impl ContractEnv { .set_dictionary_value(dictionary_name, key, cl_value); } + /// Removes the dictionary from the contract storage. + pub fn remove_dictionary>(&self, dictionary_name: U) { + let dictionary_name = dictionary_name.as_ref(); + self.backend.borrow().remove_dictionary(dictionary_name); + } + /// Returns the address of the caller of the contract. pub fn caller(&self) -> Address { let backend = self.backend.borrow(); diff --git a/examples/Cargo.toml b/examples/Cargo.toml index 72a2d9ed..eba5aa84 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -48,6 +48,12 @@ path = "bin/cep18_on_livenet.rs" required-features = ["livenet"] test = false +[[bin]] +name = "cep78_on_livenet" +path = "bin/cep78_on_livenet.rs" +required-features = ["livenet"] +test = false + [[bin]] name = "livenet_tests" path = "bin/livenet_tests.rs" diff --git a/examples/bin/cep78_on_livenet.rs b/examples/bin/cep78_on_livenet.rs new file mode 100644 index 00000000..987b87a6 --- /dev/null +++ b/examples/bin/cep78_on_livenet.rs @@ -0,0 +1,79 @@ +//! Deploys a CEP-78 contract, mints an nft token and transfers it to another address. +use std::str::FromStr; + +use odra::args::Maybe; +use odra::casper_types::U256; +use odra::host::{Deployer, HostEnv, HostRef, HostRefLoader}; +use odra::Address; +use odra_modules::cep78::modalities::{ + EventsMode, MetadataMutability, NFTIdentifierMode, NFTKind, NFTMetadataKind, OwnershipMode +}; +use odra_modules::cep78::token::{Cep78HostRef, Cep78InitArgs}; +use odra_modules::cep78::utils::InitArgsBuilder; + +const CEP78_METADATA: &str = r#"{ + "name": "John Doe", + "token_uri": "https://www.barfoo.com", + "checksum": "940bffb3f2bba35f84313aa26da09ece3ad47045c6a1292c2bbd2df4ab1a55fb" +}"#; +const CASPER_CONTRACT_ADDRESS: &str = + "hash-d4b8fa492d55ac7a515c0c6043d72ba43c49cd120e7ba7eec8c0a330dedab3fb"; +const ODRA_CONTRACT_ADDRESS: &str = + "hash-3d35238431c5c6fa1d7df70d73bfc2efd5a03fd5af99ab8c7828a56b2f330274"; +const RECIPIENT_ADDRESS: &str = + "hash-7821386ecdda83ff100379a06558b69a675d5a170d1c5bf5fbe9fd35262d091f"; + +fn main() { + let env = odra_casper_livenet_env::env(); + + // Deploy new contract. + let mut token = deploy_contract(&env); + println!("Token address: {}", token.address().to_string()); + + // Uncomment to load existing contract. + // let mut token = load_contract(&env, CASPER_CONTRACT_ADDRESS); + // println!("Token name: {}", token.get_collection_name()); + + env.set_gas(3_000_000_000u64); + let owner = env.caller(); + let recipient = + Address::from_str(RECIPIENT_ADDRESS).expect("Should be a valid recipient address"); + // casper contract may return a result or not, so deserialization may fail and it's better to use `try_transfer`/`try_mint`/`try_burn` methods + let _ = token.try_mint(owner, CEP78_METADATA.to_string(), Maybe::None); + println!("Owner's balance: {:?}", token.balance_of(owner)); + println!("Recipient's balance: {:?}", token.balance_of(recipient)); + let token_id = token.get_number_of_minted_tokens() - 1; + let _ = token.try_transfer(Maybe::Some(token_id), Maybe::None, owner, recipient); + + println!("Owner's balance: {:?}", token.balance_of(owner)); + println!("Recipient's balance: {:?}", token.balance_of(recipient)); +} + +/// Loads a Cep78 contract. +pub fn load_contract(env: &HostEnv, address: &str) -> Cep78HostRef { + let address = Address::from_str(address).expect("Should be a valid contract address"); + Cep78HostRef::load(env, address) +} + +/// Deploys a Cep78 contract. +pub fn deploy_contract(env: &HostEnv) -> Cep78HostRef { + let name: String = String::from("PlascoinCollection with CES"); + let symbol = String::from("CEP78-PLS-CES"); + let receipt_name = String::from("PlascoinReceipt"); + + let init_args = InitArgsBuilder::default() + .collection_name(name) + .collection_symbol(symbol) + .total_token_supply(1_000) + .ownership_mode(OwnershipMode::Transferable) + .nft_metadata_kind(NFTMetadataKind::CEP78) + .identifier_mode(NFTIdentifierMode::Ordinal) + .nft_kind(NFTKind::Digital) + .metadata_mutability(MetadataMutability::Mutable) + .receipt_name(receipt_name) + .events_mode(EventsMode::CES) + .build(); + + env.set_gas(400_000_000_000u64); + Cep78HostRef::deploy(env, init_args) +} diff --git a/modules/Cargo.toml b/modules/Cargo.toml index 5796fca6..916f1844 100644 --- a/modules/Cargo.toml +++ b/modules/Cargo.toml @@ -11,11 +11,16 @@ categories = ["wasm"] [dependencies] odra = { path = "../odra", version = "0.9.1", default-features = false } -hex = { version = "0.4.3", default-features = false } +serde = { version = "1.0.80", default-features = false } +serde_json = { version = "1.0.59", default-features = false } +serde-json-wasm = { version = "1.0.1", default-features = false } +base16 = { version = "0.2.1", default-features = false } base64 = { version = "0.22.0", default-features = false, features = ["alloc"] } [dev-dependencies] odra-test = { path = "../odra-test", version = "0.9.1" } +once_cell = "1" +blake2 = "0.10.6" [build-dependencies] odra-build = { path = "../odra-build", version = "0.9.1" } diff --git a/modules/Odra.toml b/modules/Odra.toml index 649b3a0a..2e455f4d 100644 --- a/modules/Odra.toml +++ b/modules/Odra.toml @@ -22,6 +22,18 @@ fqn = "access::Ownable" [[contracts]] fqn = "ownable_2step::Ownable2Step" +[[contracts]] +fqn = "cep78::token::Cep78" + +[[contracts]] +fqn = "cep78::utils::MockContract" + +[[contracts]] +fqn = "cep78::utils::MockDummyContract" + +[[contracts]] +fqn = "cep78::utils::MockTransferFilterContract" + [[contracts]] fqn = "cep18_token::Cep18" diff --git a/modules/src/cep18/storage.rs b/modules/src/cep18/storage.rs index bdc995da..c519d30f 100644 --- a/modules/src/cep18/storage.rs +++ b/modules/src/cep18/storage.rs @@ -120,13 +120,13 @@ impl Cep18BalancesStorage { /// Sets the balance of the given account. pub fn set(&self, account: &Address, balance: U256) { self.env() - .set_dictionary_value(BALANCES_KEY, self.key(account), balance); + .set_dictionary_value(BALANCES_KEY, self.key(account).as_bytes(), balance); } /// Gets the balance of the given account. pub fn get_or_default(&self, account: &Address) -> U256 { self.env() - .get_dictionary_value(BALANCES_KEY, self.key(account)) + .get_dictionary_value(BALANCES_KEY, self.key(account).as_bytes()) .unwrap_or_default() } @@ -168,13 +168,13 @@ impl Cep18AllowancesStorage { /// Sets the allowance of the given owner and spender. pub fn set(&self, owner: &Address, spender: &Address, amount: U256) { self.env() - .set_dictionary_value(ALLOWANCES_KEY, self.key(owner, spender), amount); + .set_dictionary_value(ALLOWANCES_KEY, &self.key(owner, spender), amount); } /// Gets the allowance of the given owner and spender. pub fn get_or_default(&self, owner: &Address, spender: &Address) -> U256 { self.env() - .get_dictionary_value(ALLOWANCES_KEY, self.key(owner, spender)) + .get_dictionary_value(ALLOWANCES_KEY, &self.key(owner, spender)) .unwrap_or_default() } @@ -196,12 +196,14 @@ impl Cep18AllowancesStorage { self.set(owner, spender, new_allowance); } - fn key(&self, owner: &Address, spender: &Address) -> String { + fn key(&self, owner: &Address, spender: &Address) -> [u8; 64] { + let mut result = [0u8; 64]; let mut preimage = Vec::new(); preimage.append(&mut owner.to_bytes().unwrap_or_revert(&self.env())); preimage.append(&mut spender.to_bytes().unwrap_or_revert(&self.env())); let key_bytes = self.env().hash(&preimage); - hex::encode(key_bytes) + odra::utils::hex_to_slice(&key_bytes, &mut result); + result } } diff --git a/modules/src/cep78/constants.rs b/modules/src/cep78/constants.rs new file mode 100644 index 00000000..aa270b1f --- /dev/null +++ b/modules/src/cep78/constants.rs @@ -0,0 +1,39 @@ +pub const ACL_PACKAGE_MODE: &str = "acl_package_mode"; +pub const ACL_WHITELIST: &str = "acl_whitelist"; +pub const ALLOW_MINTING: &str = "allow_minting"; +pub const APPROVED: &str = "approved"; +pub const BURN_MODE: &str = "burn_mode"; +pub const BURNT_TOKENS: &str = "burnt_tokens"; +pub const COLLECTION_NAME: &str = "collection_name"; +pub const COLLECTION_SYMBOL: &str = "collection_symbol"; +pub const EVENTS_MODE: &str = "events_mode"; +pub const HOLDER_MODE: &str = "holder_mode"; +pub const IDENTIFIER_MODE: &str = "identifier_mode"; +pub const INSTALLER: &str = "installer"; +pub const JSON_SCHEMA: &str = "json_schema"; +pub const METADATA_MUTABILITY: &str = "metadata_mutability"; +pub const MINTING_MODE: &str = "minting_mode"; +pub const NFT_KIND: &str = "nft_kind"; +pub const NFT_METADATA_KIND: &str = "nft_metadata_kind"; +pub const NFT_METADATA_KINDS: &str = "nft_metadata_kinds"; +pub const NUMBER_OF_MINTED_TOKENS: &str = "number_of_minted_tokens"; +pub const OPERATORS: &str = "operators"; +pub const OPERATOR_BURN_MODE: &str = "operator_burn_mode"; +pub const OWNERSHIP_MODE: &str = "ownership_mode"; +pub const RECEIPT_NAME: &str = "receipt_name"; +pub const REPORTING_MODE: &str = "reporting_mode"; +pub const TOKEN_COUNT: &str = "balances"; +pub const TOKEN_ISSUERS: &str = "token_issuers"; +pub const TOKEN_OWNERS: &str = "token_owners"; +pub const TOTAL_TOKEN_SUPPLY: &str = "total_token_supply"; +pub const TRANSFER_FILTER_CONTRACT: &str = "transfer_filter_contract"; +pub const WHITELIST_MODE: &str = "whitelist_mode"; +pub const PREFIX_PAGE_DICTIONARY: &str = "page"; + +pub const METADATA_CEP78: &str = "metadata_cep78"; +pub const METADATA_CUSTOM_VALIDATED: &str = "metadata_custom_validated"; +pub const METADATA_NFT721: &str = "metadata_nft721"; +pub const METADATA_RAW: &str = "metadata_raw"; + +// The cap on the amount of tokens within a given CEP-78 collection. +pub const MAX_TOTAL_TOKEN_SUPPLY: u64 = 1_000_000u64; diff --git a/modules/src/cep78/data.rs b/modules/src/cep78/data.rs new file mode 100644 index 00000000..d2b6fffa --- /dev/null +++ b/modules/src/cep78/data.rs @@ -0,0 +1,190 @@ +use crate::{ + base64_encoded_key_value_storage, compound_key_value_storage, key_value_storage, + single_value_storage +}; +use odra::{casper_types::bytesrepr::ToBytes, prelude::*, Address, SubModule, UnwrapOrRevert}; + +use super::constants::*; +use super::error::CEP78Error; + +single_value_storage!( + Cep78CollectionName, + String, + COLLECTION_NAME, + CEP78Error::MissingCollectionName +); +single_value_storage!( + Cep78CollectionSymbol, + String, + COLLECTION_SYMBOL, + CEP78Error::MissingCollectionName +); +single_value_storage!( + Cep78TotalSupply, + u64, + TOTAL_TOKEN_SUPPLY, + CEP78Error::MissingTotalTokenSupply +); +single_value_storage!(Cep78TokenCounter, u64, NUMBER_OF_MINTED_TOKENS); +impl Cep78TokenCounter { + pub fn add(&mut self, value: u64) { + match self.get() { + Some(current_value) => self.set(value + current_value), + None => self.set(value) + } + } +} +single_value_storage!( + Cep78Installer, + Address, + INSTALLER, + CEP78Error::MissingInstaller +); +compound_key_value_storage!(Cep78Operators, OPERATORS, Address, bool); +key_value_storage!(Cep78Owners, TOKEN_OWNERS, Address); +key_value_storage!(Cep78Issuers, TOKEN_ISSUERS, Address); +key_value_storage!(Cep78BurntTokens, BURNT_TOKENS, ()); +base64_encoded_key_value_storage!(Cep78TokenCount, TOKEN_COUNT, Address, u64); +key_value_storage!(Cep78Approved, APPROVED, Option
); + +#[odra::module] +pub struct CollectionData { + name: SubModule, + symbol: SubModule, + total_token_supply: SubModule, + minted_tokens_count: SubModule, + installer: SubModule, + owners: SubModule, + issuers: SubModule, + approved: SubModule, + token_count: SubModule, + burnt_tokens: SubModule, + operators: SubModule +} + +impl CollectionData { + pub fn init( + &mut self, + name: String, + symbol: String, + total_token_supply: u64, + installer: Address + ) { + if total_token_supply == 0 { + self.env().revert(CEP78Error::CannotInstallWithZeroSupply) + } + + if total_token_supply > MAX_TOTAL_TOKEN_SUPPLY { + self.env().revert(CEP78Error::ExceededMaxTotalSupply) + } + + self.name.set(name); + self.symbol.set(symbol); + self.total_token_supply.set(total_token_supply); + self.installer.set(installer); + } + + #[inline] + pub fn installer(&self) -> Address { + self.installer.get() + } + + #[inline] + pub fn total_token_supply(&self) -> u64 { + self.total_token_supply.get() + } + + #[inline] + pub fn increment_number_of_minted_tokens(&mut self) { + self.minted_tokens_count.add(1); + } + + #[inline] + pub fn number_of_minted_tokens(&self) -> u64 { + self.minted_tokens_count.get().unwrap_or_default() + } + + #[inline] + pub fn collection_name(&self) -> String { + self.name.get() + } + + #[inline] + pub fn collection_symbol(&self) -> String { + self.symbol.get() + } + + #[inline] + pub fn set_owner(&mut self, token_id: &str, token_owner: Address) { + self.owners.set(token_id, token_owner); + } + + #[inline] + pub fn set_issuer(&mut self, token_id: &str, issuer: Address) { + self.issuers.set(token_id, issuer); + } + + #[inline] + pub fn increment_counter(&mut self, token_owner: &Address) { + let value = self.token_count.get(token_owner).unwrap_or_default(); + self.token_count.set(token_owner, value + 1); + } + + #[inline] + pub fn decrement_counter(&mut self, token_owner: &Address) { + let value = self.token_count.get(token_owner).unwrap_or_default(); + self.token_count.set(token_owner, value - 1); + } + + #[inline] + pub fn operator(&self, owner: Address, operator: Address) -> bool { + self.operators.get_or_default(&owner, &operator) + } + + #[inline] + pub fn set_operator(&mut self, owner: Address, operator: Address, approved: bool) { + self.operators.set(&owner, &operator, approved); + } + + #[inline] + pub fn mark_burnt(&mut self, token_id: &str) { + self.burnt_tokens.set(token_id, ()); + } + + #[inline] + pub fn is_burnt(&self, token_id: &str) -> bool { + self.burnt_tokens.get(token_id).is_some() + } + + #[inline] + pub fn issuer(&self, token_id: &str) -> Address { + self.issuers + .get(token_id) + .unwrap_or_revert_with(&self.env(), CEP78Error::InvalidTokenIdentifier) + } + + #[inline] + pub fn approve(&mut self, token_id: &str, operator: Address) { + self.approved.set(token_id, Some(operator)); + } + + #[inline] + pub fn revoke(&mut self, token_id: &str) { + self.approved.set(token_id, None); + } + + #[inline] + pub fn approved(&self, token_id: &str) -> Option
{ + self.approved.get(token_id).flatten() + } + + #[inline] + pub fn owner_of(&self, token_id: &str) -> Option
{ + self.owners.get(token_id) + } + + #[inline] + pub fn token_count(&self, owner: &Address) -> u64 { + self.token_count.get(owner).unwrap_or_default() + } +} diff --git a/modules/src/cep78/error.rs b/modules/src/cep78/error.rs new file mode 100644 index 00000000..4b7a26b2 --- /dev/null +++ b/modules/src/cep78/error.rs @@ -0,0 +1,173 @@ +#[odra::odra_error] +pub enum CEP78Error { + InvalidAccount = 1, + MissingInstaller = 2, + InvalidInstaller = 3, + UnexpectedKeyVariant = 4, + MissingTokenOwner = 5, + InvalidTokenOwner = 6, + FailedToGetArgBytes = 7, + FailedToCreateDictionary = 8, + MissingStorageUref = 9, + InvalidStorageUref = 10, + MissingOwnersUref = 11, + InvalidOwnersUref = 12, + FailedToAccessStorageDictionary = 13, + FailedToAccessOwnershipDictionary = 14, + DuplicateMinted = 15, + FailedToConvertToCLValue = 16, + MissingCollectionName = 17, + InvalidCollectionName = 18, + FailedToSerializeMetaData = 19, + MissingAccount = 20, + MissingMintingStatus = 21, + InvalidMintingStatus = 22, + MissingCollectionSymbol = 23, + InvalidCollectionSymbol = 24, + MissingTotalTokenSupply = 25, + InvalidTotalTokenSupply = 26, + MissingTokenID = 27, + InvalidTokenIdentifier = 28, + MissingTokenOwners = 29, + MissingAccountHash = 30, + InvalidAccountHash = 31, + TokenSupplyDepleted = 32, + MissingOwnedTokensDictionary = 33, + TokenAlreadyBelongsToMinterFatal = 34, + FatalTokenIdDuplication = 35, + InvalidMinter = 36, + MissingMintingMode = 37, + InvalidMintingMode = 38, + MissingInstallerKey = 39, + FailedToConvertToAccountHash = 40, + InvalidBurner = 41, + PreviouslyBurntToken = 42, + MissingAllowMinting = 43, + InvalidAllowMinting = 44, + MissingNumberOfMintedTokens = 45, + InvalidNumberOfMintedTokens = 46, + MissingTokenMetaData = 47, + InvalidTokenMetaData = 48, + MissingApprovedAccountHash = 49, + InvalidApprovedAccountHash = 50, + MissingApprovedTokensDictionary = 51, + TokenAlreadyApproved = 52, + MissingApproveAll = 53, + InvalidApproveAll = 54, + MissingOperator = 55, + InvalidOperator = 56, + Phantom = 57, + ContractAlreadyInitialized = 58, + MintingIsPaused = 59, + FailureToParseAccountHash = 60, + VacantValueInDictionary = 61, + MissingOwnershipMode = 62, + InvalidOwnershipMode = 63, + InvalidTokenMinter = 64, + MissingOwnedTokens = 65, + InvalidAccountKeyInDictionary = 66, + MissingJsonSchema = 67, + InvalidJsonSchema = 68, + InvalidKey = 69, + InvalidOwnedTokens = 70, + MissingTokenURI = 71, + InvalidTokenURI = 72, + MissingNftKind = 73, + InvalidNftKind = 74, + MissingHolderMode = 75, + InvalidHolderMode = 76, + MissingWhitelistMode = 77, + InvalidWhitelistMode = 78, + MissingContractWhiteList = 79, + InvalidContractWhitelist = 80, + UnlistedContractHash = 81, + InvalidContract = 82, + EmptyContractWhitelist = 83, + MissingReceiptName = 84, + InvalidReceiptName = 85, + InvalidJsonMetadata = 86, + InvalidJsonFormat = 87, + FailedToParseCep78Metadata = 88, + FailedToParse721Metadata = 89, + FailedToParseCustomMetadata = 90, + InvalidCEP78Metadata = 91, + FailedToJsonifyCEP78Metadata = 92, + InvalidNFT721Metadata = 93, + FailedToJsonifyNFT721Metadata = 94, + InvalidCustomMetadata = 95, + MissingNFTMetadataKind = 96, + InvalidNFTMetadataKind = 97, + MissingIdentifierMode = 98, + InvalidIdentifierMode = 99, + FailedToParseTokenId = 100, + MissingMetadataMutability = 101, + InvalidMetadataMutability = 102, + FailedToJsonifyCustomMetadata = 103, + ForbiddenMetadataUpdate = 104, + MissingBurnMode = 105, + InvalidBurnMode = 106, + MissingHashByIndex = 107, + InvalidHashByIndex = 108, + MissingIndexByHash = 109, + InvalidIndexByHash = 110, + MissingPageTableURef = 111, + InvalidPageTableURef = 112, + MissingPageLimit = 113, + InvalidPageLimit = 114, + InvalidPageNumber = 115, + InvalidPageIndex = 116, + MissingUnmatchedHashCount = 117, + InvalidUnmatchedHashCount = 118, + MissingPackageHashForUpgrade = 119, + MissingPageUref = 120, + InvalidPageUref = 121, + CannotUpgradeWithZeroSupply = 122, + CannotInstallWithZeroSupply = 123, + MissingMigrationFlag = 124, + InvalidMigrationFlag = 125, + ContractAlreadyMigrated = 126, + UnregisteredOwnerInMint = 127, + UnregisteredOwnerInTransfer = 128, + MissingReportingMode = 129, + InvalidReportingMode = 130, + MissingPage = 131, + UnregisteredOwnerFromMigration = 132, + ExceededMaxTotalSupply = 133, + MissingCep78PackageHash = 134, + InvalidCep78InvalidHash = 135, + InvalidPackageHashName = 136, + InvalidAccessKeyName = 137, + InvalidCheckForUpgrade = 138, + InvalidNamedKeyConvention = 139, + OwnerReverseLookupModeNotTransferable = 140, + InvalidAdditionalRequiredMetadata = 141, + InvalidOptionalMetadata = 142, + MissingOptionalNFTMetadataKind = 143, + InvalidOptionalNFTMetadataKind = 144, + MissingAdditionalNFTMetadataKind = 145, + InvalidAdditionalNFTMetadataKind = 146, + InvalidRequirement = 147, + MissingEventsMode = 148, + InvalidEventsMode = 149, + CannotUpgradeToMoreSupply = 150, + MissingOperatorDict = 151, + MissingApprovedDict = 152, + MissingSpenderAccountHash = 153, + InvalidSpenderAccountHash = 154, + MissingOwnerTokenIdentifierKey = 155, + InvalidTransferFilterContract = 156, + MissingTransferFilterContract = 157, + TransferFilterContractNeedsTransferableMode = 158, + TransferFilterContractDenied = 159, + MissingACLWhiteList = 160, + InvalidACLWhitelist = 161, + EmptyACLWhitelist = 162, + InvalidACLPackageMode = 163, + MissingACLPackageMode = 164, + InvalidPackageOperatorMode = 165, + MissingPackageOperatorMode = 166, + InvalidOperatorBurnMode = 167, + MissingOperatorBurnMode = 168, + InvalidIdentifier = 169, + DuplicateIdentifier = 170 +} diff --git a/modules/src/cep78/events.rs b/modules/src/cep78/events.rs new file mode 100644 index 00000000..090c9732 --- /dev/null +++ b/modules/src/cep78/events.rs @@ -0,0 +1,60 @@ +use odra::{prelude::*, Address}; + +#[odra::event] +pub struct Mint { + recipient: Address, + token_id: String, + data: String +} + +#[odra::event] +pub struct Burn { + owner: Address, + token_id: String, + burner: Address +} + +#[odra::event] +pub struct Approval { + owner: Address, + spender: Address, + token_id: String +} + +#[odra::event] +pub struct ApprovalRevoked { + owner: Address, + token_id: String +} + +#[odra::event] +pub struct ApprovalForAll { + owner: Address, + operator: Address +} + +#[odra::event] +pub struct RevokedForAll { + owner: Address, + operator: Address +} + +#[odra::event] +pub struct Transfer { + owner: Address, + spender: Option
, + recipient: Address, + token_id: String +} + +#[odra::event] +pub struct MetadataUpdated { + token_id: String, + data: String +} + +#[odra::event] +pub struct VariablesSet {} + +#[odra::event] +pub struct Migration {} diff --git a/modules/src/cep78/metadata.rs b/modules/src/cep78/metadata.rs new file mode 100644 index 00000000..59c9adf5 --- /dev/null +++ b/modules/src/cep78/metadata.rs @@ -0,0 +1,353 @@ +use odra::{args::Maybe, prelude::*, SubModule, UnwrapOrRevert}; +use serde::{Deserialize, Serialize}; + +use crate::single_value_storage; + +use super::{ + constants::{ + IDENTIFIER_MODE, JSON_SCHEMA, METADATA_MUTABILITY, NFT_METADATA_KIND, NFT_METADATA_KINDS + }, + constants::{METADATA_CEP78, METADATA_CUSTOM_VALIDATED, METADATA_NFT721, METADATA_RAW}, + error::CEP78Error, + modalities::{ + MetadataMutability, MetadataRequirement, NFTIdentifierMode, NFTMetadataKind, Requirement, + TokenIdentifier + } +}; + +single_value_storage!( + Cep78MetadataRequirement, + MetadataRequirement, + NFT_METADATA_KINDS, + CEP78Error::MissingNFTMetadataKind +); +single_value_storage!( + Cep78NFTMetadataKind, + NFTMetadataKind, + NFT_METADATA_KIND, + CEP78Error::MissingNFTMetadataKind +); +single_value_storage!( + Cep78IdentifierMode, + NFTIdentifierMode, + IDENTIFIER_MODE, + CEP78Error::MissingIdentifierMode +); +single_value_storage!( + Cep78MetadataMutability, + MetadataMutability, + METADATA_MUTABILITY, + CEP78Error::MissingMetadataMutability +); +single_value_storage!( + Cep78JsonSchema, + String, + JSON_SCHEMA, + CEP78Error::MissingJsonSchema +); + +#[odra::module] +pub struct Cep78ValidatedMetadata; + +#[odra::module] +impl Cep78ValidatedMetadata { + #[allow(clippy::ptr_arg)] + pub fn set(&self, kind: &NFTMetadataKind, token_id: &String, value: String) { + let dictionary_name = get_metadata_key(kind); + self.env() + .set_dictionary_value(dictionary_name, token_id.as_bytes(), value); + } + #[allow(clippy::ptr_arg)] + pub fn get(&self, kind: &NFTMetadataKind, token_id: &String) -> String { + let dictionary_name = get_metadata_key(kind); + let env = self.env(); + env.get_dictionary_value(dictionary_name, token_id.as_bytes()) + .unwrap_or_revert_with(&env, CEP78Error::InvalidTokenIdentifier) + } +} + +#[odra::module] +pub struct Metadata { + requirements: SubModule, + identifier_mode: SubModule, + mutability: SubModule, + json_schema: SubModule, + validated_metadata: SubModule, + nft_metadata_kind: SubModule +} + +impl Metadata { + pub fn init( + &mut self, + base_metadata_kind: NFTMetadataKind, + additional_required_metadata: Maybe>, + optional_metadata: Maybe>, + metadata_mutability: MetadataMutability, + identifier_mode: NFTIdentifierMode, + json_schema: String + ) { + let mut requirements = MetadataRequirement::new(); + for optional in optional_metadata.unwrap_or_default() { + requirements.insert(optional, Requirement::Optional); + } + for required in additional_required_metadata.unwrap_or_default() { + requirements.insert(required, Requirement::Required); + } + requirements.insert(base_metadata_kind.clone(), Requirement::Required); + + // Attempt to parse the provided schema if the `CustomValidated` metadata kind is required or + // optional and fail installation if the schema cannot be parsed. + if let Some(req) = requirements.get(&NFTMetadataKind::CustomValidated) { + if req == &Requirement::Required || req == &Requirement::Optional { + serde_json_wasm::from_str::(&json_schema) + .map_err(|_| CEP78Error::InvalidJsonSchema) + .unwrap_or_revert(&self.env()); + } + } + self.nft_metadata_kind.set(base_metadata_kind); + self.requirements.set(requirements); + self.identifier_mode.set(identifier_mode); + self.mutability.set(metadata_mutability); + self.json_schema.set(json_schema); + } + + pub fn get_requirements(&self) -> MetadataRequirement { + self.requirements.get() + } + + pub fn get_identifier_mode(&self) -> NFTIdentifierMode { + self.identifier_mode.get() + } + + pub fn get_or_revert(&self, token_identifier: &TokenIdentifier) -> String { + let env = self.env(); + let metadata_kind_list = self.get_requirements(); + + for (metadata_kind, required) in metadata_kind_list { + match required { + Requirement::Required => { + let id = token_identifier.to_string(); + let metadata = self.validated_metadata.get(&metadata_kind, &id); + return metadata; + } + _ => continue + } + } + env.revert(CEP78Error::MissingTokenMetaData) + } + + // test only + pub fn get_metadata_by_kind(&self, token_identifier: String, kind: &NFTMetadataKind) -> String { + self.validated_metadata.get(kind, &token_identifier) + } + + pub fn get_metadata_kind(&self) -> NFTMetadataKind { + self.nft_metadata_kind.get() + } + + pub fn ensure_mutability(&self, error: CEP78Error) { + let current_mutability = self.mutability.get(); + if current_mutability != MetadataMutability::Mutable { + self.env().revert(error) + } + } + + pub fn update_or_revert(&mut self, token_metadata: &str, token_id: &String) { + let requirements = self.get_requirements(); + for (metadata_kind, required) in requirements { + if required == Requirement::Unneeded { + continue; + } + let token_metadata_validation = self.validate(&metadata_kind, token_metadata); + match token_metadata_validation { + Ok(validated_token_metadata) => { + self.validated_metadata + .set(&metadata_kind, token_id, validated_token_metadata); + } + Err(err) => { + self.env().revert(err); + } + } + } + } + + fn validate(&self, kind: &NFTMetadataKind, metadata: &str) -> Result { + let token_schema = self.get_metadata_schema(kind); + match kind { + NFTMetadataKind::CEP78 => { + let metadata = serde_json_wasm::from_str::(metadata) + .map_err(|_| CEP78Error::FailedToParseCep78Metadata)?; + + if let Some(name_property) = token_schema.properties.get("name") { + if name_property.required && metadata.name.is_empty() { + self.env().revert(CEP78Error::InvalidCEP78Metadata) + } + } + if let Some(token_uri_property) = token_schema.properties.get("token_uri") { + if token_uri_property.required && metadata.token_uri.is_empty() { + self.env().revert(CEP78Error::InvalidCEP78Metadata) + } + } + if let Some(checksum_property) = token_schema.properties.get("checksum") { + if checksum_property.required && metadata.checksum.is_empty() { + self.env().revert(CEP78Error::InvalidCEP78Metadata) + } + } + serde_json::to_string_pretty(&metadata) + .map_err(|_| CEP78Error::FailedToJsonifyCEP78Metadata) + } + NFTMetadataKind::NFT721 => { + let metadata = serde_json_wasm::from_str::(metadata) + .map_err(|_| CEP78Error::FailedToParse721Metadata)?; + + if let Some(name_property) = token_schema.properties.get("name") { + if name_property.required && metadata.name.is_empty() { + self.env().revert(CEP78Error::InvalidNFT721Metadata) + } + } + if let Some(token_uri_property) = token_schema.properties.get("token_uri") { + if token_uri_property.required && metadata.token_uri.is_empty() { + self.env().revert(CEP78Error::InvalidNFT721Metadata) + } + } + if let Some(symbol_property) = token_schema.properties.get("symbol") { + if symbol_property.required && metadata.symbol.is_empty() { + self.env().revert(CEP78Error::InvalidNFT721Metadata) + } + } + serde_json::to_string_pretty(&metadata) + .map_err(|_| CEP78Error::FailedToJsonifyNFT721Metadata) + } + NFTMetadataKind::Raw => Ok(metadata.to_owned()), + NFTMetadataKind::CustomValidated => { + let custom_metadata = + serde_json_wasm::from_str::>(metadata) + .map(|attributes| CustomMetadata { attributes }) + .map_err(|_| CEP78Error::FailedToParseCustomMetadata)?; + + for (property_name, property_type) in token_schema.properties.iter() { + if property_type.required + && custom_metadata.attributes.get(property_name).is_none() + { + self.env().revert(CEP78Error::InvalidCustomMetadata) + } + } + serde_json::to_string_pretty(&custom_metadata.attributes) + .map_err(|_| CEP78Error::FailedToJsonifyCustomMetadata) + } + } + } + + fn get_metadata_schema(&self, kind: &NFTMetadataKind) -> CustomMetadataSchema { + match kind { + NFTMetadataKind::Raw => CustomMetadataSchema { + properties: BTreeMap::new() + }, + NFTMetadataKind::NFT721 => { + let mut properties = BTreeMap::new(); + properties.insert( + "name".to_string(), + MetadataSchemaProperty { + name: "name".to_string(), + description: "The name of the NFT".to_string(), + required: true + } + ); + properties.insert( + "symbol".to_string(), + MetadataSchemaProperty { + name: "symbol".to_string(), + description: "The symbol of the NFT collection".to_string(), + required: true + } + ); + properties.insert( + "token_uri".to_string(), + MetadataSchemaProperty { + name: "token_uri".to_string(), + description: "The URI pointing to an off chain resource".to_string(), + required: true + } + ); + CustomMetadataSchema { properties } + } + NFTMetadataKind::CEP78 => { + let mut properties = BTreeMap::new(); + properties.insert( + "name".to_string(), + MetadataSchemaProperty { + name: "name".to_string(), + description: "The name of the NFT".to_string(), + required: true + } + ); + properties.insert( + "token_uri".to_string(), + MetadataSchemaProperty { + name: "token_uri".to_string(), + description: "The URI pointing to an off chain resource".to_string(), + required: true + } + ); + properties.insert( + "checksum".to_string(), + MetadataSchemaProperty { + name: "checksum".to_string(), + description: "A SHA256 hash of the content at the token_uri".to_string(), + required: true + } + ); + CustomMetadataSchema { properties } + } + NFTMetadataKind::CustomValidated => { + serde_json_wasm::from_str::(&self.json_schema.get()) + .map_err(|_| CEP78Error::InvalidJsonSchema) + .unwrap_or_revert(&self.env()) + } + } + } +} + +#[derive(Serialize, Deserialize)] +#[odra::odra_type] +pub(crate) struct MetadataSchemaProperty { + pub name: String, + pub description: String, + pub required: bool +} + +#[derive(Serialize, Deserialize, Clone)] +pub(crate) struct CustomMetadataSchema { + pub properties: BTreeMap +} + +// Using a structure for the purposes of serialization formatting. +#[derive(Serialize, Deserialize)] +pub(crate) struct MetadataNFT721 { + name: String, + symbol: String, + token_uri: String +} + +#[derive(Serialize, Deserialize)] +pub(crate) struct MetadataCEP78 { + name: String, + token_uri: String, + checksum: String +} + +// Using a structure for the purposes of serialization formatting. +#[derive(Serialize, Deserialize)] +pub(crate) struct CustomMetadata { + attributes: BTreeMap +} + +pub(crate) fn get_metadata_key(metadata_kind: &NFTMetadataKind) -> String { + match metadata_kind { + NFTMetadataKind::CEP78 => METADATA_CEP78, + NFTMetadataKind::NFT721 => METADATA_NFT721, + NFTMetadataKind::Raw => METADATA_RAW, + NFTMetadataKind::CustomValidated => METADATA_CUSTOM_VALIDATED + } + .to_string() +} diff --git a/modules/src/cep78/mod.rs b/modules/src/cep78/mod.rs new file mode 100644 index 00000000..f0ca8b97 --- /dev/null +++ b/modules/src/cep78/mod.rs @@ -0,0 +1,15 @@ +#![allow(missing_docs)] + +mod constants; +mod data; +pub mod error; +pub mod events; +pub mod metadata; +pub mod modalities; +mod reverse_lookup; +pub mod settings; +#[cfg(test)] +mod tests; +pub mod token; +pub mod utils; +pub mod whitelist; diff --git a/modules/src/cep78/modalities.rs b/modules/src/cep78/modalities.rs new file mode 100644 index 00000000..f8761eee --- /dev/null +++ b/modules/src/cep78/modalities.rs @@ -0,0 +1,459 @@ +use super::error::CEP78Error; +use odra::prelude::*; + +/// The WhitelistMode dictates if the ACL whitelist restricting access to +/// the mint entry point can be updated. +#[repr(u8)] +#[odra::odra_type] +#[derive(Default)] +pub enum WhitelistMode { + /// The ACL whitelist is unlocked and can be updated via the `set_variables` endpoint. + #[default] + Unlocked = 0, + /// The ACL whitelist is locked and cannot be updated further. + Locked = 1 +} + +impl TryFrom for WhitelistMode { + type Error = CEP78Error; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(WhitelistMode::Unlocked), + 1 => Ok(WhitelistMode::Locked), + _ => Err(CEP78Error::InvalidWhitelistMode) + } + } +} + +/// The modality dictates which entities on a Casper network can own and mint NFTs. +/// +/// If the NFTHolderMode is set to Contracts a ContractHash whitelist must be provided. +/// This whitelist dictates which Contracts are allowed to mint NFTs in the restricted +/// Installer minting mode. +/// +/// This modality is an optional installation parameter and will default to the Mixed mode +/// if not provided. However, this mode cannot be changed once the contract has been installed. +#[repr(u8)] +#[odra::odra_type] +#[derive(Copy, Default)] +pub enum NFTHolderMode { + /// Only Accounts can own and mint NFTs. + Accounts = 0, + /// Only Contracts can own and mint NFTs. + Contracts = 1, + /// Both Accounts and Contracts can own and mint NFTs. + #[default] + Mixed = 2 +} + +impl TryFrom for NFTHolderMode { + type Error = CEP78Error; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(NFTHolderMode::Accounts), + 1 => Ok(NFTHolderMode::Contracts), + 2 => Ok(NFTHolderMode::Mixed), + _ => Err(CEP78Error::InvalidHolderMode) + } + } +} + +/// The minting mode governs the behavior of contract when minting new tokens. +/// +/// This modality is an optional installation parameter and will default +/// to the `Installer` mode if not provided. However, this mode cannot be changed +/// once the contract has been installed. +#[odra::odra_type] +#[repr(u8)] +#[derive(Default)] +pub enum MintingMode { + /// The ability to mint NFTs is restricted to the installing account only. + #[default] + Installer = 0, + /// The ability to mint NFTs is not restricted. + Public = 1, + /// The ability to mint NFTs is restricted by an ACL. + Acl = 2 +} + +impl TryFrom for MintingMode { + type Error = CEP78Error; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(MintingMode::Installer), + 1 => Ok(MintingMode::Public), + 2 => Ok(MintingMode::Acl), + _ => Err(CEP78Error::InvalidMintingMode) + } + } +} + +#[repr(u8)] +#[odra::odra_type] +#[derive(Default)] +pub enum NFTKind { + /// The NFT represents a real-world physical + /// like a house. + #[default] + Physical = 0, + /// The NFT represents a digital asset like a unique + /// JPEG or digital art. + Digital = 1, + /// The NFT is the virtual representation + /// of a physical notion, e.g a patent + /// or copyright. + Virtual = 2 +} + +impl TryFrom for NFTKind { + type Error = CEP78Error; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(NFTKind::Physical), + 1 => Ok(NFTKind::Digital), + 2 => Ok(NFTKind::Virtual), + _ => Err(CEP78Error::InvalidNftKind) + } + } +} + +pub type MetadataRequirement = BTreeMap; + +#[odra::odra_type] +#[repr(u8)] +pub enum Requirement { + Required = 0, + Optional = 1, + Unneeded = 2 +} + +impl TryFrom for Requirement { + type Error = CEP78Error; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(Requirement::Required), + 1 => Ok(Requirement::Optional), + 2 => Ok(Requirement::Unneeded), + _ => Err(CEP78Error::InvalidRequirement) + } + } +} + +/// This modality dictates the schema for the metadata for NFTs minted +/// by a given instance of an NFT contract. +#[repr(u8)] +#[derive(Default, PartialOrd, Ord)] +#[odra::odra_type] +pub enum NFTMetadataKind { + /// NFTs must have valid metadata conforming to the CEP-78 schema. + #[default] + CEP78 = 0, + /// NFTs must have valid metadata conforming to the NFT-721 metadata schema. + NFT721 = 1, + /// Metadata validation will not occur and raw strings can be passed to + /// `token_metadata` runtime argument as part of the call to mint entrypoint. + Raw = 2, + /// Custom schema provided at the time of install will be used when validating + /// the metadata as part of the call to mint entrypoint. + CustomValidated = 3 +} + +impl TryFrom for NFTMetadataKind { + type Error = CEP78Error; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(NFTMetadataKind::CEP78), + 1 => Ok(NFTMetadataKind::NFT721), + 2 => Ok(NFTMetadataKind::Raw), + 3 => Ok(NFTMetadataKind::CustomValidated), + _ => Err(CEP78Error::InvalidNFTMetadataKind) + } + } +} + +/// This modality specifies the behavior regarding ownership of NFTs and whether +/// the owner of the NFT can change over the contract's lifetime. +/// +/// Ownership mode is a required installation parameter and cannot be changed +/// once the contract has been installed. +#[repr(u8)] +#[odra::odra_type] +#[derive(Default, PartialOrd, Ord, Copy)] +pub enum OwnershipMode { + /// The minter owns it and can never transfer it. + #[default] + Minter = 0, + /// The minter assigns it to an address and can never be transferred. + Assigned = 1, + /// The NFT can be transferred even to an recipient that does not exist. + Transferable = 2 +} + +impl TryFrom for OwnershipMode { + type Error = CEP78Error; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(OwnershipMode::Minter), + 1 => Ok(OwnershipMode::Assigned), + 2 => Ok(OwnershipMode::Transferable), + _ => Err(CEP78Error::InvalidOwnershipMode) + } + } +} + +/// The identifier mode governs the primary identifier for NFTs minted +/// for a given instance on an installed contract. +/// +/// Since the default primary identifier in the `Hash` mode is custom or derived by +/// hashing over the metadata, making it a content-addressed identifier, +/// the metadata for the minted NFT cannot be updated after the mint. +/// +/// Attempting to install the contract with the [MetadataMutability] modality set to +/// `Mutable` in the `Hash` identifier mode will raise an error. +/// +/// This modality is a required installation parameter and cannot be changed +/// once the contract has been installed. +#[repr(u8)] +#[odra::odra_type] +#[derive(Default, PartialOrd, Ord, Copy)] +pub enum NFTIdentifierMode { + /// NFTs minted in this modality are identified by a u64 value. + /// This value is determined by the number of NFTs minted by + /// the contract at the time the NFT is minted. + #[default] + Ordinal = 0, + /// NFTs minted in this modality are identified by an optional custom + /// string identifier or by default a base16 encoded representation of + /// the blake2b hash of the metadata provided at the time of mint. + Hash = 1 +} + +impl TryFrom for NFTIdentifierMode { + type Error = CEP78Error; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(NFTIdentifierMode::Ordinal), + 1 => Ok(NFTIdentifierMode::Hash), + _ => Err(CEP78Error::InvalidIdentifierMode) + } + } +} + +/// The metadata mutability mode governs the behavior around updates to a given NFTs metadata. +/// +/// The Mutable option cannot be used in conjunction with the Hash modality for the NFT identifier; +/// attempting to install the contract with this configuration raises +/// [super::error::CEP78Error::InvalidMetadataMutability] error. +/// +/// This modality is a required installation parameter and cannot be changed +/// once the contract has been installed. +#[repr(u8)] +#[derive(Default, PartialOrd, Ord, Copy)] +#[odra::odra_type] +pub enum MetadataMutability { + /// Metadata for NFTs minted in this mode cannot be updated once the NFT has been minted. + #[default] + Immutable = 0, + /// Metadata for NFTs minted in this mode can update the metadata via the `set_token_metadata` entrypoint. + Mutable = 1 +} + +impl TryFrom for MetadataMutability { + type Error = CEP78Error; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(MetadataMutability::Immutable), + 1 => Ok(MetadataMutability::Mutable), + _ => Err(CEP78Error::InvalidMetadataMutability) + } + } +} + +#[odra::odra_type] +pub enum TokenIdentifier { + Index(u64), + Hash(String) +} + +impl TokenIdentifier { + pub fn new_index(index: u64) -> Self { + TokenIdentifier::Index(index) + } + + pub fn new_hash(hash: String) -> Self { + TokenIdentifier::Hash(hash) + } + + pub fn get_index(&self) -> Option { + if let Self::Index(index) = self { + return Some(*index); + } + None + } + + pub fn get_hash(&self) -> Option { + if let Self::Hash(hash) = self { + return Some(hash.to_owned()); + } + None + } + + pub fn get_dictionary_item_key(&self) -> String { + match self { + TokenIdentifier::Index(token_index) => token_index.to_string(), + TokenIdentifier::Hash(hash) => hash.clone() + } + } +} + +impl ToString for TokenIdentifier { + fn to_string(&self) -> String { + match self { + TokenIdentifier::Index(index) => index.to_string(), + TokenIdentifier::Hash(hash) => hash.to_string() + } + } +} + +/// The modality dictates whether tokens minted by a given instance of +/// an NFT contract can be burnt. +#[repr(u8)] +#[odra::odra_type] +#[derive(Default)] +pub enum BurnMode { + /// Minted tokens can be burnt. + #[default] + Burnable = 0, + /// Minted tokens cannot be burnt. + NonBurnable = 1 +} + +impl TryFrom for BurnMode { + type Error = CEP78Error; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(BurnMode::Burnable), + 1 => Ok(BurnMode::NonBurnable), + _ => Err(CEP78Error::InvalidBurnMode) + } + } +} + +/// This modality is set at install and determines if a given contract instance +/// writes necessary data to allow reverse lookup by owner in addition to by ID. +/// +/// This modality provides the following options: +/// +/// `NoLookup`: The reporting and receipt functionality is not supported. +/// In this option, the contract instance does not maintain a reverse lookup +/// database of ownership and therefore has more predictable gas costs and greater +/// scaling. +/// `Complete`: The reporting and receipt functionality is supported. Token +/// ownership will be tracked by the contract instance using the system described +/// [here](https://github.com/casper-ecosystem/cep-78-enhanced-nft/blob/dev/docs/reverse-lookup.md#owner-reverse-lookup-functionality). +/// `TransfersOnly`: The reporting and receipt functionality is supported like +/// `Complete`. However, it does not begin tracking until the first transfer. +/// This modality is for use cases where the majority of NFTs are owned by +/// a private minter and only NFT's that have been transferred benefit from +/// reverse lookup tracking. Token ownership will also be tracked by the contract +/// instance using the system described [here](https://github.com/casper-ecosystem/cep-78-enhanced-nft/blob/dev/docs/reverse-lookup.md#owner-reverse-lookup-functionality). +/// +/// Additionally, when set to Complete, causes a receipt to be returned by the mint +/// or transfer entrypoints, which the caller can store in their account or contract +/// context for later reference. +/// +/// Further, two special entrypoints are enabled in Complete mode. First, +/// `register_owner` which when called will allocate the necessary tracking +/// record for the imputed entity. This allows isolation of the one time gas cost +/// to do this per owner, which is convenient for accounting purposes. Second, +/// updated_receipts, which allows an owner of one or more NFTs held by the contract +/// instance to attain up to date receipt information for the NFTs they currently own. +#[repr(u8)] +#[derive(Default, PartialOrd, Ord, Copy)] +#[odra::odra_type] +pub enum OwnerReverseLookupMode { + /// The reporting and receipt functionality is not supported. + #[default] + NoLookUp = 0, + /// The reporting and receipt functionality is supported. + Complete = 1, + /// The reporting and receipt functionality is supported, but the tracking + /// does not start until the first transfer. + TransfersOnly = 2 +} + +impl TryFrom for OwnerReverseLookupMode { + type Error = CEP78Error; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(OwnerReverseLookupMode::NoLookUp), + 1 => Ok(OwnerReverseLookupMode::Complete), + 2 => Ok(OwnerReverseLookupMode::TransfersOnly), + _ => Err(CEP78Error::InvalidReportingMode) + } + } +} + +/// The `EventsMode` modality determines how the installed instance of CEP-78 +/// will handle the recording of events that occur from interacting with +/// the contract. +/// +/// Odra does not allow to set the `CEP47` event schema. +#[repr(u8)] +#[odra::odra_type] +#[derive(Copy, Default)] +#[allow(clippy::upper_case_acronyms)] +pub enum EventsMode { + /// Signals the contract to not record events at all. This is the default mode. + #[default] + NoEvents = 0, + /// Signals the contract to record events using the Casper Event Standard. + CES = 2 +} + +impl TryFrom for EventsMode { + type Error = CEP78Error; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(EventsMode::NoEvents), + 2 => Ok(EventsMode::CES), + _ => Err(CEP78Error::InvalidEventsMode) + } + } +} + +/// The transfer filter modality, if enabled, specifies a contract package hash +/// pointing to a contract that will be called when the transfer method is +/// invoked on the contract. CEP-78 will call the `can_transfer` method on the +/// specified callback contract, which is expected to return a value of +/// `TransferFilterContractResult`, represented as a u8. +#[repr(u8)] +#[non_exhaustive] +#[odra::odra_type] +pub enum TransferFilterContractResult { + /// Blocks the transfer regardless of the outcome of other checks + DenyTransfer = 0, + /// Allows the transfer to proceed if other checks also pass + ProceedTransfer +} + +impl From for TransferFilterContractResult { + fn from(value: u8) -> Self { + match value { + 0 => TransferFilterContractResult::DenyTransfer, + _ => TransferFilterContractResult::ProceedTransfer + } + } +} diff --git a/modules/src/cep78/reverse_lookup.rs b/modules/src/cep78/reverse_lookup.rs new file mode 100644 index 00000000..d86930a8 --- /dev/null +++ b/modules/src/cep78/reverse_lookup.rs @@ -0,0 +1,304 @@ +use odra::{ + args::Maybe, + casper_types::{AccessRights, URef}, + prelude::*, + Address, Mapping, SubModule, UnwrapOrRevert +}; + +use crate::single_value_storage; + +use super::{ + constants::PREFIX_PAGE_DICTIONARY, + constants::{RECEIPT_NAME, REPORTING_MODE}, + error::CEP78Error, + modalities::{OwnerReverseLookupMode, OwnershipMode, TokenIdentifier} +}; +// The size of a given page, it is currently set to 1000 +// to ease the math around addressing newly minted tokens. +pub const PAGE_SIZE: u64 = 1000; + +single_value_storage!( + Cep78OwnerReverseLookupMode, + OwnerReverseLookupMode, + REPORTING_MODE, + CEP78Error::InvalidReportingMode +); +single_value_storage!( + Cep78ReceiptName, + String, + RECEIPT_NAME, + CEP78Error::InvalidReceiptName +); + +#[odra::module] +pub struct ReverseLookup { + mode: SubModule, + hash_by_index: Mapping, + index_by_hash: Mapping, + page_table: Mapping>, + pages: Mapping<(String, u64, Address), Vec>, + receipt_name: SubModule +} + +impl ReverseLookup { + pub fn init(&mut self, mode: OwnerReverseLookupMode, receipt_name: String) { + self.mode.set(mode); + self.receipt_name.set(receipt_name); + } + + pub fn insert_hash( + &mut self, + current_number_of_minted_tokens: u64, + token_identifier: &TokenIdentifier + ) { + if token_identifier.get_index().is_some() { + return; + } + if self + .index_by_hash + .get(&token_identifier.to_string()) + .is_some() + { + self.env().revert(CEP78Error::DuplicateIdentifier) + } + if self + .hash_by_index + .get(¤t_number_of_minted_tokens) + .is_some() + { + self.env().revert(CEP78Error::DuplicateIdentifier) + } + + self.hash_by_index.set( + ¤t_number_of_minted_tokens, + token_identifier.get_hash().unwrap_or_revert(&self.env()) + ); + self.index_by_hash.set( + &token_identifier.to_string(), + current_number_of_minted_tokens + ); + } + + pub fn register_owner( + &mut self, + owner: Maybe
, + ownership_mode: OwnershipMode + ) -> String { + let mode = self.get_mode(); + if [ + OwnerReverseLookupMode::Complete, + OwnerReverseLookupMode::TransfersOnly + ] + .contains(&mode) + { + let owner = match ownership_mode { + OwnershipMode::Minter => self.env().caller(), + OwnershipMode::Assigned | OwnershipMode::Transferable => owner.unwrap(&self.env()) + }; + if self.page_table.get(&owner).is_none() { + self.page_table.set(&owner, vec![false; PAGE_SIZE as usize]); + } + + // let page_table_uref = utils::get_uref( + // PAGE_TABLE, + // NFTCoreError::MissingPageTableURef, + // NFTCoreError::InvalidPageTableURef, + // ); + + // let owner_item_key = utils::encode_dictionary_item_key(owner_key); + + // if storage::dictionary_get::>(page_table_uref, &owner_item_key) + // .unwrap_or_revert() + // .is_none() + // { + // let page_table_width = utils::get_stored_value_with_user_errors::( + // PAGE_LIMIT, + // NFTCoreError::MissingPageLimit, + // NFTCoreError::InvalidPageLimit, + // ); + // storage::dictionary_put( + // page_table_uref, + // &owner_item_key, + // vec![false; page_table_width as usize], + // ); + // } + // let collection_name = utils::get_stored_value_with_user_errors::( + // COLLECTION_NAME, + // NFTCoreError::MissingCollectionName, + // NFTCoreError::InvalidCollectionName, + // ); + // let package_uref = storage::new_uref(utils::get_stored_value_with_user_errors::( + // &format!("{PREFIX_CEP78}_{collection_name}"), + // NFTCoreError::MissingCep78PackageHash, + // NFTCoreError::InvalidCep78InvalidHash, + // )); + // runtime::ret(CLValue::from_t((collection_name, package_uref)).unwrap_or_revert()) + } + // ("".to_string(), URef::new([255; 32], AccessRights::READ_ADD_WRITE)) + "".to_string() + } + + pub fn on_mint( + &mut self, + tokens_count: u64, + token_owner: Address, + token_id: String + ) -> (String, Address, String) { + if self.get_mode() == OwnerReverseLookupMode::Complete { + let (page_table_entry, _page_uref) = + self.add_page_entry_and_page_record(tokens_count, &token_owner, true); + + let receipt_name = self.receipt_name.get(); + let receipt_string = format!("{receipt_name}_m_{PAGE_SIZE}_p_{page_table_entry}"); + // TODO: Implement the following + // let receipt_address = Key::dictionary(page_uref, owned_tokens_item.as_bytes()); + // should not return `token_owner` + return (receipt_string, token_owner, token_id); + } + ("".to_string(), token_owner, token_id) + } + + pub fn on_transfer( + &mut self, + token_identifier: TokenIdentifier, + source: Address, + _target: Address + ) -> (String, Address) { + let mode = self.get_mode(); + if let OwnerReverseLookupMode::Complete | OwnerReverseLookupMode::TransfersOnly = mode { + // Update to_account owned_tokens. Revert if owned_tokens list is not found + let tokens_count = self.get_token_index(&token_identifier); + if OwnerReverseLookupMode::TransfersOnly == mode { + self.add_page_entry_and_page_record(tokens_count, &source, false); + } + // TODO: Implement the following + + // let (page_table_entry, _page_uref) = + // self.update_page_entry_and_page_record(tokens_count, &source, &target); + // let receipt_name = self.receipt_name.get_or_default(); + // let _receipt_string = format!("{receipt_name}_m_{PAGE_SIZE}_p_{page_table_entry}"); + // let receipt_address = Key::dictionary(page_uref, owned_tokens_item_key.as_bytes()); + // return (receipt_string, source); + } + ("".to_owned(), source) + } + + fn add_page_entry_and_page_record( + &mut self, + tokens_count: u64, + item_key: &Address, + on_mint: bool + ) -> (u64, URef) { + // there is an explicit page_table; + // this is the entry in that overall page table which maps to the underlying page + // upon which this mint's address will exist + let page_table_entry = tokens_count / PAGE_SIZE; + let page_address = tokens_count % PAGE_SIZE; + + let mut page_table = match self.page_table.get(item_key) { + Some(page_table) => page_table, + None => self.env().revert(if on_mint { + CEP78Error::UnregisteredOwnerInMint + } else { + CEP78Error::UnregisteredOwnerInTransfer + }) + }; + + let page_key = ( + PREFIX_PAGE_DICTIONARY.to_string(), + page_table_entry, + *item_key + ); + let mut page = if !page_table[page_table_entry as usize] { + // We mark the page table entry to true to signal the allocation of a page. + let _ = core::mem::replace(&mut page_table[page_table_entry as usize], true); + self.pages.set(&page_key, page_table); + vec![false; PAGE_SIZE as usize] + } else { + self.pages + .get(&page_key) + .unwrap_or_revert_with(&self.env(), CEP78Error::MissingPage) + }; + + let _ = core::mem::replace(&mut page[page_address as usize], true); + + self.pages.set(&page_key, page); + // storage::dictionary_put(page_uref, item_key, page); + let addr_array = [0u8; 32]; + let uref_a = URef::new(addr_array, AccessRights::READ); + // (page_table_entry, page_uref) + (page_table_entry, uref_a) + } + + fn _update_page_entry_and_page_record( + &mut self, + tokens_count: u64, + old_item_key: &Address, + new_item_key: &Address + ) -> (u64, URef) { + let page_table_entry = tokens_count / PAGE_SIZE; + let page_address = tokens_count % PAGE_SIZE; + + let old_page_key = ( + PREFIX_PAGE_DICTIONARY.to_string(), + page_table_entry, + *old_item_key + ); + let new_page_key = ( + PREFIX_PAGE_DICTIONARY.to_string(), + page_table_entry, + *new_item_key + ); + + let mut source_page = self + .pages + .get(&old_page_key) + .unwrap_or_revert_with(&self.env(), CEP78Error::InvalidPageNumber); + + if !source_page[page_address as usize] { + self.env().revert(CEP78Error::InvalidTokenIdentifier) + } + + let _ = core::mem::replace(&mut source_page[page_address as usize], false); + + self.pages.set(&old_page_key, source_page); + + let mut target_page_table = self + .page_table + .get(new_item_key) + .unwrap_or_revert_with(&self.env(), CEP78Error::UnregisteredOwnerInTransfer); + + let mut target_page = if !target_page_table[page_table_entry as usize] { + // Create a new page here + let _ = core::mem::replace(&mut target_page_table[page_table_entry as usize], true); + self.page_table.set(new_item_key, target_page_table); + vec![false; PAGE_SIZE as usize] + } else { + self.pages.get(&new_page_key).unwrap_or_revert(&self.env()) + }; + + let _ = core::mem::replace(&mut target_page[page_address as usize], true); + + self.pages.set(&new_page_key, target_page); + // (page_table_entry, page_uref) + let addr_array = [0u8; 32]; + let uref_a = URef::new(addr_array, AccessRights::READ); + // (page_table_entry, page_uref) + (page_table_entry, uref_a) + } + + fn get_token_index(&self, token_identifier: &TokenIdentifier) -> u64 { + match token_identifier { + TokenIdentifier::Index(token_index) => *token_index, + TokenIdentifier::Hash(_) => self + .index_by_hash + .get(&token_identifier.to_string()) + .unwrap_or_revert_with(&self.env(), CEP78Error::InvalidTokenIdentifier) + } + } + + #[inline] + fn get_mode(&self) -> OwnerReverseLookupMode { + self.mode.get() + } +} diff --git a/modules/src/cep78/settings.rs b/modules/src/cep78/settings.rs new file mode 100644 index 00000000..ded913c1 --- /dev/null +++ b/modules/src/cep78/settings.rs @@ -0,0 +1,131 @@ +use crate::single_value_storage; +use odra::{prelude::*, SubModule}; + +use super::constants::*; +use super::error::CEP78Error; +use super::modalities::{BurnMode, EventsMode, MintingMode, NFTHolderMode, NFTKind, OwnershipMode}; + +single_value_storage!(Cep78AllowMinting, bool, ALLOW_MINTING); +single_value_storage!( + Cep78MintingMode, + MintingMode, + MINTING_MODE, + CEP78Error::MissingMintingMode +); +single_value_storage!( + Cep78OwnershipMode, + OwnershipMode, + OWNERSHIP_MODE, + CEP78Error::MissingOwnershipMode +); +single_value_storage!(Cep78NFTKind, NFTKind, NFT_KIND, CEP78Error::MissingNftKind); +single_value_storage!( + Cep78HolderMode, + NFTHolderMode, + HOLDER_MODE, + CEP78Error::MissingHolderMode +); +single_value_storage!( + Cep78BurnMode, + BurnMode, + BURN_MODE, + CEP78Error::MissingBurnMode +); +single_value_storage!( + Cep78EventsMode, + EventsMode, + EVENTS_MODE, + CEP78Error::MissingEventsMode +); +single_value_storage!( + Cep78OperatorBurnMode, + bool, + OPERATOR_BURN_MODE, + CEP78Error::MissingOperatorBurnMode +); + +#[odra::module] +pub struct Settings { + allow_minting: SubModule, + minting_mode: SubModule, + ownership_mode: SubModule, + nft_kind: SubModule, + holder_mode: SubModule, + burn_mode: SubModule, + events_mode: SubModule, + operator_burn_mode: SubModule +} + +impl Settings { + #[allow(clippy::too_many_arguments)] + pub fn init( + &mut self, + allow_minting: bool, + minting_mode: MintingMode, + ownership_mode: OwnershipMode, + nft_kind: NFTKind, + holder_mode: NFTHolderMode, + burn_mode: BurnMode, + events_mode: EventsMode, + operator_burn_mode: bool + ) { + self.allow_minting.set(allow_minting); + self.minting_mode.set(minting_mode); + self.ownership_mode.set(ownership_mode); + self.nft_kind.set(nft_kind); + self.holder_mode.set(holder_mode); + self.burn_mode.set(burn_mode); + self.events_mode.set(events_mode); + self.operator_burn_mode.set(operator_burn_mode); + } + + #[inline] + pub fn allow_minting(&self) -> bool { + self.allow_minting.get().unwrap_or_default() + } + + #[inline] + pub fn set_allow_minting(&mut self, value: bool) { + self.allow_minting.set(value) + } + + #[inline] + pub fn events_mode(&self) -> EventsMode { + self.events_mode.get() + } + + #[inline] + pub fn burn_mode(&self) -> BurnMode { + self.burn_mode.get() + } + + #[inline] + pub fn ownership_mode(&self) -> OwnershipMode { + self.ownership_mode.get() + } + + #[inline] + pub fn minting_mode(&self) -> MintingMode { + self.minting_mode.get() + } + + #[inline] + pub fn holder_mode(&self) -> NFTHolderMode { + self.holder_mode.get() + } + + #[inline] + pub fn operator_burn_mode(&self) -> bool { + self.operator_burn_mode.get() + } + + #[inline] + pub fn nft_kind(&self) -> NFTKind { + self.nft_kind.get() + } + + #[inline] + pub fn set_operator_burn_mode(&mut self, value: bool) { + self.operator_burn_mode.set(value) + } +} diff --git a/modules/src/cep78/tests/acl.rs b/modules/src/cep78/tests/acl.rs new file mode 100644 index 00000000..c50b0492 --- /dev/null +++ b/modules/src/cep78/tests/acl.rs @@ -0,0 +1,433 @@ +use odra::{ + args::Maybe, + host::{Deployer, HostEnv, HostRef, NoArgs}, + prelude::* +}; + +use crate::cep78::{ + error::CEP78Error, + modalities::{MintingMode, NFTHolderMode, OwnershipMode, WhitelistMode}, + tests::utils::TEST_PRETTY_721_META_DATA, + token::Cep78HostRef, + utils::{MockContractHostRef, MockDummyContractHostRef} +}; + +use super::default_args_builder; + +#[test] +fn should_install_with_acl_whitelist() { + let env = odra_test::env(); + let test_contract_address = MockContractHostRef::deploy(&env, NoArgs); + let contract_whitelist = vec![*test_contract_address.address()]; + let args = default_args_builder() + .holder_mode(NFTHolderMode::Contracts) + .whitelist_mode(WhitelistMode::Locked) + .minting_mode(MintingMode::Acl) + .acl_white_list(contract_whitelist) + .build(); + let contract = Cep78HostRef::deploy(&env, args); + + assert_eq!(WhitelistMode::Locked, contract.get_whitelist_mode()); + let is_whitelisted_contract = contract.is_whitelisted(test_contract_address.address()); + assert!(is_whitelisted_contract, "acl whitelist is incorrectly set"); +} + +#[test] +#[ignore = "No need to implement a contract whitelist"] +fn should_install_with_deprecated_contract_whitelist() {} + +#[test] +fn should_not_install_with_minting_mode_not_acl_if_acl_whitelist_provided() { + let env = odra_test::env(); + + let contract_whitelist = vec![env.get_account(0)]; + + let args = default_args_builder() + .holder_mode(NFTHolderMode::Contracts) + .whitelist_mode(WhitelistMode::Locked) + .ownership_mode(OwnershipMode::Transferable) + .minting_mode(MintingMode::Installer) // Not the right minting mode for acl + .acl_white_list(contract_whitelist) + .build(); + + let init_result = Cep78HostRef::try_deploy(&env, args); + assert_eq!( + init_result.err(), + Some(CEP78Error::InvalidMintingMode.into()), + "should disallow installing with minting mode not acl if acl whitelist provided" + ); +} + +fn should_allow_installation_of_contract_with_empty_locked_whitelist_in_public_mode_with_holder_mode( + env: &HostEnv, + nft_holder_mode: NFTHolderMode +) { + let args = default_args_builder() + .holder_mode(nft_holder_mode) + .whitelist_mode(WhitelistMode::Locked) + .minting_mode(MintingMode::Public) + .build(); + + Cep78HostRef::deploy(env, args); +} + +#[test] +fn should_allow_installation_of_contract_with_empty_locked_whitelist_in_public_mode() { + let env = odra_test::env(); + should_allow_installation_of_contract_with_empty_locked_whitelist_in_public_mode_with_holder_mode( + &env, + NFTHolderMode::Accounts, + ); + should_allow_installation_of_contract_with_empty_locked_whitelist_in_public_mode_with_holder_mode( + &env, + NFTHolderMode::Contracts, + ); + should_allow_installation_of_contract_with_empty_locked_whitelist_in_public_mode_with_holder_mode( + &env, + NFTHolderMode::Mixed + ); +} + +// TODO: in Odra contract installation always succeeds, so this test is not applicable +#[test] +#[ignore = "in Odra contract installation always succeeds, so this test is not applicable"] +fn should_disallow_installation_with_contract_holder_mode_and_installer_mode() { + let env = odra_test::env(); + let contract_whitelist = vec![env.get_account(1), env.get_account(2), env.get_account(3)]; + + let args = default_args_builder() + .holder_mode(NFTHolderMode::Contracts) + .whitelist_mode(WhitelistMode::Locked) + .ownership_mode(OwnershipMode::Minter) + .minting_mode(MintingMode::Installer) + .acl_white_list(contract_whitelist) + .build(); + + Cep78HostRef::deploy(&env, args); + // builder.exec(install_request).expect_failure(); + // let error = builder.get_error().expect("should have an error"); + // assert_expected_error(error, 38, "Invalid MintingMode (not ACL) and NFTHolderMode"); +} + +#[test] +fn should_allow_whitelisted_account_to_mint() { + let env = odra_test::env(); + + let account_user_1 = env.get_account(1); + let account_whitelist = vec![account_user_1]; + + let args = default_args_builder() + .holder_mode(NFTHolderMode::Accounts) + .whitelist_mode(WhitelistMode::Locked) + .ownership_mode(OwnershipMode::Minter) + .minting_mode(MintingMode::Acl) + .acl_white_list(account_whitelist) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + assert!( + contract.is_whitelisted(&account_user_1), + "acl whitelist is incorrectly set" + ); + + env.set_caller(account_user_1); + contract.mint( + account_user_1, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let token_id = 0u64; + let actual_token_owner = contract.owner_of(Maybe::Some(token_id), Maybe::None); + + assert_eq!(actual_token_owner, account_user_1); +} + +#[test] +fn should_disallow_unlisted_account_from_minting() { + let env = odra_test::env(); + let account = env.get_account(0); + let account_whitelist = vec![account]; + + let args = default_args_builder() + .holder_mode(NFTHolderMode::Accounts) + .whitelist_mode(WhitelistMode::Locked) + .ownership_mode(OwnershipMode::Minter) + .minting_mode(MintingMode::Acl) + .acl_white_list(account_whitelist) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + assert!( + contract.is_whitelisted(&account), + "acl whitelist is incorrectly set" + ); + let account_user_1 = env.get_account(1); + + env.set_caller(account_user_1); + assert_eq!( + contract.try_mint( + account_user_1, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ), + Err(CEP78Error::InvalidMinter.into()), + "Unlisted account hash should not be permitted to mint" + ); +} + +#[test] +fn should_allow_whitelisted_contract_to_mint() { + let env = odra_test::env(); + + let mut minting_contract = MockContractHostRef::deploy(&env, NoArgs); + + let contract_whitelist = vec![*minting_contract.address()]; + let args = default_args_builder() + .holder_mode(NFTHolderMode::Contracts) + .whitelist_mode(WhitelistMode::Locked) + .ownership_mode(OwnershipMode::Minter) + .minting_mode(MintingMode::Acl) + .acl_white_list(contract_whitelist) + .build(); + let contract = Cep78HostRef::deploy(&env, args); + assert!( + contract.is_whitelisted(minting_contract.address()), + "acl whitelist is incorrectly set" + ); + minting_contract.set_address(contract.address()); + minting_contract.mint(TEST_PRETTY_721_META_DATA.to_string(), false); + + let token_id = 0u64; + let actual_token_owner = contract.owner_of(Maybe::Some(token_id), Maybe::None); + assert_eq!(&actual_token_owner, minting_contract.address()) +} + +#[test] +fn should_disallow_unlisted_contract_from_minting() { + let env = odra_test::env(); + + let mut minting_contract = MockContractHostRef::deploy(&env, NoArgs); + + let contract_whitelist = vec![env.get_account(1), env.get_account(2), env.get_account(3)]; + let args = default_args_builder() + .holder_mode(NFTHolderMode::Contracts) + .whitelist_mode(WhitelistMode::Locked) + .ownership_mode(OwnershipMode::Minter) + .minting_mode(MintingMode::Acl) + .acl_white_list(contract_whitelist) + .build(); + let contract = Cep78HostRef::deploy(&env, args); + minting_contract.set_address(contract.address()); + + assert_eq!( + minting_contract.try_mint(TEST_PRETTY_721_META_DATA.to_string(), false), + Err(CEP78Error::UnlistedContractHash.into()), + "Unlisted account hash should not be permitted to mint" + ); +} + +#[test] +fn should_allow_mixed_account_contract_to_mint() { + let env = odra_test::env(); + + let mut minting_contract = MockContractHostRef::deploy(&env, NoArgs); + let account_user_1 = env.get_account(1); + let mixed_whitelist = vec![*minting_contract.address(), account_user_1]; + + let args = default_args_builder() + .holder_mode(NFTHolderMode::Mixed) + .whitelist_mode(WhitelistMode::Locked) + .ownership_mode(OwnershipMode::Minter) + .minting_mode(MintingMode::Acl) + .acl_white_list(mixed_whitelist) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + minting_contract.set_address(contract.address()); + + assert!( + contract.is_whitelisted(minting_contract.address()), + "acl whitelist is incorrectly set" + ); + + minting_contract.mint(TEST_PRETTY_721_META_DATA.to_string(), false); + + let token_id = 0u64; + let actual_token_owner = contract.owner_of(Maybe::Some(token_id), Maybe::None); + assert_eq!(&actual_token_owner, minting_contract.address()); + + assert!( + contract.is_whitelisted(&account_user_1), + "acl whitelist is incorrectly set" + ); + env.set_caller(account_user_1); + contract.mint( + account_user_1, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let token_id = 1u64; + let actual_token_owner = contract.owner_of(Maybe::Some(token_id), Maybe::None); + assert_eq!(actual_token_owner, account_user_1) +} + +#[test] +fn should_disallow_unlisted_contract_from_minting_with_mixed_account_contract() { + let env = odra_test::env(); + + let mut minting_contract = MockContractHostRef::deploy(&env, NoArgs); + let account_user_1 = env.get_account(1); + let mixed_whitelist = vec![ + *MockDummyContractHostRef::deploy(&env, NoArgs).address(), + account_user_1, + ]; + + let args = default_args_builder() + .holder_mode(NFTHolderMode::Mixed) + .whitelist_mode(WhitelistMode::Locked) + .ownership_mode(OwnershipMode::Minter) + .minting_mode(MintingMode::Acl) + .acl_white_list(mixed_whitelist) + .build(); + let contract = Cep78HostRef::deploy(&env, args); + minting_contract.set_address(contract.address()); + + assert_eq!( + minting_contract.try_mint(TEST_PRETTY_721_META_DATA.to_string(), false), + Err(CEP78Error::UnlistedContractHash.into()), + "Unlisted contract should not be permitted to mint" + ); +} + +#[test] +fn should_disallow_unlisted_account_from_minting_with_mixed_account_contract() { + let env = odra_test::env(); + + let minting_contract = MockContractHostRef::deploy(&env, NoArgs); + let listed_account = env.get_account(0); + let unlisted_account = env.get_account(1); + let mixed_whitelist = vec![*minting_contract.address(), listed_account]; + + let args = default_args_builder() + .holder_mode(NFTHolderMode::Mixed) + .whitelist_mode(WhitelistMode::Locked) + .ownership_mode(OwnershipMode::Minter) + .minting_mode(MintingMode::Acl) + .acl_white_list(mixed_whitelist) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + env.set_caller(unlisted_account); + assert_eq!( + contract.try_mint( + unlisted_account, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ), + Err(CEP78Error::InvalidMinter.into()), + "Unlisted account should not be permitted to mint" + ); +} + +#[test] +fn should_disallow_listed_account_from_minting_with_nftholder_contract() { + let env = odra_test::env(); + + let minting_contract = MockContractHostRef::deploy(&env, NoArgs); + let listed_account = env.get_account(0); + + let mixed_whitelist = vec![*minting_contract.address(), listed_account]; + + let args = default_args_builder() + .holder_mode(NFTHolderMode::Contracts) + .whitelist_mode(WhitelistMode::Locked) + .ownership_mode(OwnershipMode::Minter) + .minting_mode(MintingMode::Acl) + .acl_white_list(mixed_whitelist) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + assert!( + contract.is_whitelisted(&listed_account), + "acl whitelist is incorrectly set" + ); + + let unlisted_account = env.get_account(1); + env.set_caller(unlisted_account); + + assert_eq!( + contract.try_mint( + unlisted_account, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ), + Err(CEP78Error::InvalidHolderMode.into()) + ); +} + +#[test] +#[ignore = "ACL package mode package mode is switched on by default and can't be switched off"] +fn should_disallow_contract_from_whitelisted_package_to_mint_without_acl_package_mode() {} + +#[test] +#[ignore = "ACL package mode package mode is switched on by default and can't be switched off"] +fn should_allow_contract_from_whitelisted_package_to_mint_with_acl_package_mode() {} + +#[test] +#[ignore = "ACL package mode package mode is switched on by default and can't be switched off"] +fn should_allow_contract_from_whitelisted_package_to_mint_with_acl_package_mode_after_contract_upgrade( +) { +} + +// Update +#[test] +#[ignore = "Deprecated arg contract whitelist is not used in Odra"] +fn should_be_able_to_update_whitelist_for_minting_with_deprecated_arg_contract_whitelist() {} + +#[test] +fn should_be_able_to_update_whitelist_for_minting() { + let env = odra_test::env(); + + let mut minting_contract = MockContractHostRef::deploy(&env, NoArgs); + let contract_whitelist = vec![]; + + let args = default_args_builder() + .holder_mode(NFTHolderMode::Contracts) + .whitelist_mode(WhitelistMode::Unlocked) + .ownership_mode(OwnershipMode::Minter) + .minting_mode(MintingMode::Acl) + .acl_white_list(contract_whitelist) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + minting_contract.set_address(contract.address()); + + assert!( + !contract.is_whitelisted(minting_contract.address()), + "acl whitelist is incorrectly set" + ); + + assert_eq!( + minting_contract.try_mint_for(env.get_account(0), TEST_PRETTY_721_META_DATA.to_string(),), + Err(CEP78Error::UnlistedContractHash.into()), + ); + + contract.set_variables( + Maybe::None, + Maybe::Some(vec![*minting_contract.address()]), + Maybe::None + ); + + assert!( + contract.is_whitelisted(minting_contract.address()), + "acl whitelist is incorrectly set" + ); + + assert!(minting_contract + .try_mint_for(env.get_account(0), TEST_PRETTY_721_META_DATA.to_string(),) + .is_ok()); +} + +// Upgrade +#[test] +#[ignore = "Odra implements v1.5.1, so this test is not applicable"] +fn should_upgrade_from_named_keys_to_dict_and_acl_minting_mode() {} diff --git a/modules/src/cep78/tests/burn.rs b/modules/src/cep78/tests/burn.rs new file mode 100644 index 00000000..d4b384b2 --- /dev/null +++ b/modules/src/cep78/tests/burn.rs @@ -0,0 +1,314 @@ +use odra::{ + args::Maybe, + host::{Deployer, HostRef, NoArgs}, + Address +}; + +use crate::cep78::{ + error::CEP78Error, + events::Burn, + modalities::{ + BurnMode, EventsMode, MetadataMutability, MintingMode, NFTHolderMode, NFTIdentifierMode, + OwnerReverseLookupMode, OwnershipMode, TokenIdentifier + }, + tests::{default_args_builder, utils}, + token::Cep78HostRef, + utils::MockContractHostRef +}; + +use super::utils::TEST_PRETTY_721_META_DATA; + +fn should_burn_minted_token(reporting: OwnerReverseLookupMode) { + let env = odra_test::env(); + let args = default_args_builder() + .owner_reverse_lookup_mode(reporting) + .ownership_mode(OwnershipMode::Transferable) + .events_mode(EventsMode::CES) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + let token_owner = env.get_account(0); + let token_id = 0u64; + let reverse_lookup_enabled = reporting == OwnerReverseLookupMode::Complete; + mint(&mut contract, reverse_lookup_enabled, token_owner); + + let actual_balance_before_burn = contract.balance_of(token_owner); + let expected_balance_before_burn = 1u64; + assert_eq!(actual_balance_before_burn, expected_balance_before_burn); + + let burn_result = contract.try_burn(Maybe::Some(token_id), Maybe::None); + assert!(burn_result.is_ok()); + + // This will error if token is not registered as burnt. + assert!(contract.token_burned(Maybe::Some(token_id), Maybe::None)); + + // This will error if token is not registered as burnt + let actual_balance = contract.balance_of(token_owner); + let expected_balance = 0u64; + assert_eq!(actual_balance, expected_balance); + + // Expect Burn event. + let expected_event = Burn::new( + token_owner, + TokenIdentifier::Index(token_id).to_string(), + token_owner + ); + assert!( + env.emitted_event(contract.address(), &expected_event), + "Expected Burn event." + ); +} + +fn mint(contract: &mut Cep78HostRef, reverse_lookup_enabled: bool, token_owner: Address) { + if reverse_lookup_enabled { + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + /*let token_page = contract.get_token_page(token_id); + assert!(token_page[0]);*/ + } else { + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + } +} + +#[test] +fn should_burn_minted_token_with_complete_reporting() { + should_burn_minted_token(OwnerReverseLookupMode::Complete); +} + +#[test] +fn should_burn_minted_token_with_transfer_only_reporting() { + should_burn_minted_token(OwnerReverseLookupMode::TransfersOnly); +} + +#[test] +fn should_not_burn_previously_burnt_token() { + let env = odra_test::env(); + let args = default_args_builder() + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .ownership_mode(OwnershipMode::Transferable) + .events_mode(EventsMode::CES) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + let token_owner = env.get_account(0); + mint(&mut contract, true, token_owner); + + let burn_result = contract.try_burn(Maybe::Some(0u64), Maybe::None); + assert!(burn_result.is_ok()); + + let re_burn_result = contract.try_burn(Maybe::Some(0u64), Maybe::None); + assert_eq!( + re_burn_result, + Err(CEP78Error::PreviouslyBurntToken.into()), + "should disallow burning of previously burnt token" + ); +} + +#[test] +fn should_return_expected_error_when_burning_non_existing_token() { + let env = odra_test::env(); + let args = default_args_builder() + .ownership_mode(OwnershipMode::Transferable) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + let token_id = 0u64; + assert_eq!( + contract.try_burn(Maybe::Some(token_id), Maybe::None), + Err(CEP78Error::MissingOwnerTokenIdentifierKey.into()), + "should return InvalidTokenID error when trying to burn a non_existing token", + ); +} + +#[test] +fn should_return_expected_error_burning_of_others_users_token() { + let env = odra_test::env(); + let args = default_args_builder() + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .ownership_mode(OwnershipMode::Transferable) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = env.get_account(0); + let account_1 = env.get_account(1); + + mint(&mut contract, true, token_owner); + let token_id = 0u64; + /*let token_page = contract.get_token_page(token_id); + assert!(token_page[0]);*/ + env.set_caller(account_1); + assert_eq!( + contract.try_burn(Maybe::Some(token_id), Maybe::None), + Err(CEP78Error::InvalidTokenOwner.into()), + "should return InvalidTokenID error when trying to burn a non_existing token", + ); +} + +#[test] +fn should_allow_contract_to_burn_token() { + let env = odra_test::env(); + let mut minting_contract = MockContractHostRef::deploy(&env, NoArgs); + let contract_whitelist = vec![*minting_contract.address()]; + let args = default_args_builder() + .holder_mode(NFTHolderMode::Contracts) + .ownership_mode(OwnershipMode::Minter) + .minting_mode(MintingMode::Acl) + .ownership_mode(OwnershipMode::Transferable) + .acl_white_list(contract_whitelist) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + minting_contract.set_address(contract.address()); + let token_owner = env.get_account(0); + minting_contract.mint_for(token_owner, TEST_PRETTY_721_META_DATA.to_string()); + + let current_token_balance = contract.balance_of(token_owner); + assert_eq!(1u64, current_token_balance); + + contract.burn(Maybe::Some(0u64), Maybe::None); + + let updated_token_balance = contract.balance_of(token_owner); + assert_eq!(updated_token_balance, 0u64) +} + +#[test] +fn should_not_burn_in_non_burn_mode() { + let env = odra_test::env(); + let args = default_args_builder() + .ownership_mode(OwnershipMode::Transferable) + .burn_mode(BurnMode::NonBurnable) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = env.get_account(0); + mint(&mut contract, true, token_owner); + + assert_eq!( + contract.try_burn(Maybe::Some(0u64), Maybe::None), + Err(CEP78Error::InvalidBurnMode.into()) + ); +} + +#[test] +fn should_let_account_operator_burn_tokens_with_operator_burn_mode() { + let env = odra_test::env(); + let args = default_args_builder() + .ownership_mode(OwnershipMode::Transferable) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .operator_burn_mode(true) + .events_mode(EventsMode::CES) + .build(); + + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = env.get_account(0); + mint(&mut contract, true, token_owner); + + let token_id = 0u64; + let operator = env.get_account(1); + + env.set_caller(operator); + assert_eq!( + contract.try_burn(Maybe::Some(token_id), Maybe::None), + Err(CEP78Error::InvalidTokenOwner.into()), + "InvalidTokenOwner should not allow burn by non operator" + ); + + env.set_caller(token_owner); + contract.set_approval_for_all(true, operator); + + env.set_caller(operator); + assert!(contract + .try_burn(Maybe::Some(token_id), Maybe::None) + .is_ok()); + assert!(contract.token_burned(Maybe::Some(token_id), Maybe::None)); + + let actual_balance = contract.balance_of(token_owner); + let expected_balance = 0u64; + assert_eq!(actual_balance, expected_balance); + + let expected_event = Burn::new( + token_owner, + TokenIdentifier::Index(token_id).to_string(), + operator + ); + assert!(env.emitted_event(contract.address(), &expected_event)); +} + +#[test] +fn should_let_contract_operator_burn_tokens_with_operator_burn_mode() { + let env = odra_test::env(); + let args = default_args_builder() + .ownership_mode(OwnershipMode::Transferable) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .operator_burn_mode(true) + .events_mode(EventsMode::CES) + .build(); + + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = env.get_account(0); + mint(&mut contract, true, token_owner); + + let token_id = 0u64; + let mut minting_contract = MockContractHostRef::deploy(&env, NoArgs); + minting_contract.set_address(contract.address()); + let operator = *minting_contract.address(); + let account_1 = env.get_account(1); + + env.set_caller(account_1); + assert_eq!( + minting_contract.try_burn(token_id), + Err(CEP78Error::InvalidTokenOwner.into()), + "InvalidTokenOwner should not allow burn by non operator" + ); + + env.set_caller(token_owner); + contract.set_approval_for_all(true, operator); + env.set_caller(account_1); + assert!(minting_contract.try_burn(token_id).is_ok()); + + assert!(contract.token_burned(Maybe::Some(token_id), Maybe::None)); + + let actual_balance = contract.balance_of(token_owner); + let expected_balance = 0u64; + assert_eq!(actual_balance, expected_balance); + + let expected_event = Burn::new( + token_owner, + TokenIdentifier::Index(token_id).to_string(), + operator + ); + assert!(env.emitted_event(contract.address(), &expected_event)); +} + +#[test] +#[ignore = "package operator burn is on by default"] +fn should_let_package_operator_burn_tokens_with_contract_package_mode_and_operator_burn_mode() {} + +#[test] +fn should_burn_token_in_hash_identifier_mode() { + let env = odra_test::env(); + let args = default_args_builder() + .identifier_mode(NFTIdentifierMode::Hash) + .ownership_mode(OwnershipMode::Transferable) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .events_mode(EventsMode::CES) + .metadata_mutability(MetadataMutability::Immutable) + .build(); + + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = env.get_account(0); + mint(&mut contract, true, token_owner); + + let blake2b_hash = utils::create_blake2b_hash(TEST_PRETTY_721_META_DATA); + let token_hash = base16::encode_lower(&blake2b_hash); + + assert!(contract + .try_burn(Maybe::None, Maybe::Some(token_hash)) + .is_ok()); +} diff --git a/modules/src/cep78/tests/costs.rs b/modules/src/cep78/tests/costs.rs new file mode 100644 index 00000000..3f982918 --- /dev/null +++ b/modules/src/cep78/tests/costs.rs @@ -0,0 +1,95 @@ +use odra::{args::Maybe, host::Deployer}; + +use crate::cep78::{ + modalities::{ + NFTHolderMode, NFTMetadataKind, OwnerReverseLookupMode, OwnershipMode, WhitelistMode + }, + tests::{default_args_builder, utils}, + token::Cep78HostRef +}; + +#[test] +#[ignore = "Reverse lookup is not implemented yet"] +fn mint_cost_should_remain_stable() { + let env = odra_test::env(); + let args = default_args_builder() + .holder_mode(NFTHolderMode::Contracts) + .whitelist_mode(WhitelistMode::Unlocked) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .nft_metadata_kind(NFTMetadataKind::Raw) + .ownership_mode(OwnershipMode::Transferable) + .build(); + + let mut _contract = Cep78HostRef::deploy(&env, args); +} + +#[test] +fn transfer_costs_should_remain_stable() { + let env = odra_test::env(); + let args = default_args_builder() + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .nft_metadata_kind(NFTMetadataKind::Raw) + .ownership_mode(OwnershipMode::Transferable) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + let receiver = env.get_account(1); + + for _ in 0..3 { + contract.mint(token_owner, "".to_string(), Maybe::None); + } + + contract.register_owner(Maybe::Some(receiver)); + contract.transfer(Maybe::Some(0u64), Maybe::None, token_owner, receiver); + contract.transfer(Maybe::Some(1u64), Maybe::None, token_owner, receiver); + contract.transfer(Maybe::Some(2u64), Maybe::None, token_owner, receiver); + + // We check only the second and third gas costs as the first transfer cost + // has the additional gas of allocating a whole new page. Thus we ensure + // that costs once a page has been allocated remain stable. + let costs = utils::get_gas_cost_of(&env, "transfer"); + assert_eq!(costs.get(1), costs.get(2)); +} + +fn should_cost_less_when_installing_without_reverse_lookup(reporting: OwnerReverseLookupMode) { + let env = odra_test::env(); + + let args = default_args_builder() + .owner_reverse_lookup_mode(OwnerReverseLookupMode::NoLookUp) + .nft_metadata_kind(NFTMetadataKind::Raw) + .ownership_mode(OwnershipMode::Transferable) + .build(); + Cep78HostRef::deploy(&env, args); + // let page_dictionary_lookup = builder.query(None, no_lookup_hash, &["page_0".to_string()]); + // assert!(page_dictionary_lookup.is_err()); + + let args = default_args_builder() + .owner_reverse_lookup_mode(reporting) + .nft_metadata_kind(NFTMetadataKind::Raw) + .ownership_mode(OwnershipMode::Transferable) + .build(); + Cep78HostRef::deploy(&env, args); + + // let page_dictionary_lookup = builder.query(None, reverse_lookup_hash, &["page_0".to_string()]); + // assert!(page_dictionary_lookup.is_ok()); + + let costs = utils::get_deploy_gas_cost(&env); + if let Some(no_lookup_gas_cost) = costs.first() { + if let Some(reverse_lookup_gas_cost) = costs.get(1) { + assert!(no_lookup_gas_cost < reverse_lookup_gas_cost); + } + } +} + +#[test] +#[ignore = "Reverse lookup is not implemented yet"] +fn should_cost_less_when_installing_without_reverse_lookup_but_complete() { + should_cost_less_when_installing_without_reverse_lookup(OwnerReverseLookupMode::Complete); +} + +#[test] +#[ignore = "Reverse lookup is not implemented yet"] +fn should_cost_less_when_installing_without_reverse_lookup_but_transfer_only() { + should_cost_less_when_installing_without_reverse_lookup(OwnerReverseLookupMode::TransfersOnly); +} diff --git a/modules/src/cep78/tests/events.rs b/modules/src/cep78/tests/events.rs new file mode 100644 index 00000000..f963bb05 --- /dev/null +++ b/modules/src/cep78/tests/events.rs @@ -0,0 +1,82 @@ +use odra::{ + args::Maybe, + host::{Deployer, HostRef} +}; + +use crate::cep78::{ + modalities::{EventsMode, OwnerReverseLookupMode, OwnershipMode}, + tests::{default_args_builder, utils::TEST_PRETTY_721_META_DATA}, + token::Cep78HostRef +}; + +// cep47 event style +#[test] +#[ignore = "Odra does not support cep47 events"] +fn should_record_cep47_dictionary_style_mint_event() {} + +#[test] +#[ignore = "Odra does not support cep47 events"] +fn should_record_cep47_dictionary_style_transfer_token_event_in_hash_identifier_mode() {} + +#[test] +#[ignore = "Odra does not support cep47 events"] +fn should_record_cep47_dictionary_style_metadata_update_event_for_nft721_using_token_id() {} + +#[test] +#[ignore = "Odra does not support cep47 events"] +fn should_cep47_dictionary_style_burn_event() {} + +#[test] +#[ignore = "Odra does not support cep47 events"] +fn should_cep47_dictionary_style_approve_event_in_hash_identifier_mode() {} + +#[test] +#[ignore = "Odra does not support cep47 events"] +fn should_cep47_dictionary_style_approvall_for_all_event() {} + +#[test] +#[ignore = "Odra does not support cep47 events"] +fn should_cep47_dictionary_style_revoked_for_all_event() {} + +#[test] +#[ignore = "Odra does not support cep47 events"] +fn should_record_migration_event_in_cep47() {} + +#[test] +#[ignore = "Named keys existence is not verifiable in Odra"] +fn should_not_have_events_dicts_in_no_events_mode() { + let env = odra_test::env(); + let args = default_args_builder() + .events_mode(EventsMode::NoEvents) + .build(); + let _contract = Cep78HostRef::deploy(&env, args); + + // Check dict from EventsMode::CES + // let events = named_keys.get(EVENTS_DICT); + // assert_eq!(events, None); +} + +#[test] +fn should_not_record_events_in_no_events_mode() { + let env = odra_test::env(); + let args = default_args_builder() + .events_mode(EventsMode::NoEvents) + .ownership_mode(OwnershipMode::Transferable) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + // This will error if token is not registered as burnt + let actual_balance = contract.balance_of(token_owner); + let expected_balance = 1u64; + assert_eq!(actual_balance, expected_balance); + + assert!(env.events_count(contract.address()) == 0); +} diff --git a/modules/src/cep78/tests/installer.rs b/modules/src/cep78/tests/installer.rs new file mode 100644 index 00000000..1ab0988e --- /dev/null +++ b/modules/src/cep78/tests/installer.rs @@ -0,0 +1,198 @@ +use odra::host::{Deployer, HostRef, NoArgs}; + +use crate::cep78::{ + error::CEP78Error, + modalities::{ + MintingMode, NFTHolderMode, OwnerReverseLookupMode, OwnershipMode, WhitelistMode + }, + tests::{default_args_builder, COLLECTION_NAME, COLLECTION_SYMBOL}, + token::Cep78HostRef, + utils::MockDummyContractHostRef +}; + +#[test] +fn should_install_contract() { + let env = odra_test::env(); + + let args = default_args_builder() + .collection_name(COLLECTION_NAME.to_string()) + .collection_symbol(COLLECTION_SYMBOL.to_string()) + .total_token_supply(1u64) + .allow_minting(true) + .build(); + let contract = Cep78HostRef::deploy(&env, args); + + assert_eq!(&contract.get_collection_name(), COLLECTION_NAME); + assert_eq!(&contract.get_collection_symbol(), COLLECTION_SYMBOL); + assert_eq!(contract.get_total_supply(), 1u64); + assert!(contract.is_minting_allowed()); + assert_eq!(contract.get_minting_mode(), MintingMode::Installer); + assert_eq!(contract.get_number_of_minted_tokens(), 0u64); +} + +#[test] +#[ignore = "Not applicable in Odra, init is not allowed after installation by design"] +fn should_only_allow_init_during_installation_session() {} + +#[test] +fn should_install_with_allow_minting_set_to_false() { + let env = odra_test::env(); + + let args = default_args_builder().allow_minting(false).build(); + let contract = Cep78HostRef::deploy(&env, args); + assert!(!contract.is_minting_allowed()); +} + +#[test] +#[ignore = "Odra interface does not allow to pass a wrong type"] +fn should_reject_invalid_collection_name() {} +#[test] +#[ignore = "Odra interface does not allow to pass a wrong type"] +fn should_reject_invalid_collection_symbol() {} + +#[test] +#[ignore = "Odra interface does not allow to pass a wrong type"] +fn should_reject_non_numerical_total_token_supply_value() {} + +#[test] +fn should_install_with_contract_holder_mode() { + let env = odra_test::env(); + let whitelisted_contract = MockDummyContractHostRef::deploy(&env, NoArgs); + let contract_whitelist = vec![*whitelisted_contract.address()]; + let args = default_args_builder() + .holder_mode(NFTHolderMode::Contracts) + .whitelist_mode(WhitelistMode::Unlocked) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::NoLookUp) + .minting_mode(MintingMode::Acl) + .acl_white_list(contract_whitelist) + .build(); + let contract = Cep78HostRef::deploy(&env, args); + + assert_eq!( + contract.get_holder_mode(), + NFTHolderMode::Contracts, + "holder mode is not set to contracts" + ); + + assert_eq!( + contract.get_whitelist_mode(), + WhitelistMode::Unlocked, + "whitelist mode is not set to unlocked" + ); + + let is_whitelisted_account = contract.is_whitelisted(whitelisted_contract.address()); + assert!(is_whitelisted_account, "acl whitelist is incorrectly set"); +} + +fn should_disallow_installation_of_contract_with_empty_locked_whitelist_with_holder_mode( + nft_holder_mode: NFTHolderMode +) { + let env = odra_test::env(); + let args = default_args_builder() + .holder_mode(nft_holder_mode) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::NoLookUp) + .whitelist_mode(WhitelistMode::Locked) + .minting_mode(MintingMode::Acl) + .build(); + + assert_eq!( + Cep78HostRef::try_deploy(&env, args).err(), + Some(CEP78Error::EmptyACLWhitelist.into()), + "should fail execution since whitelist mode is locked and the provided whitelist is empty", + ); +} + +#[test] +fn should_disallow_installation_of_contract_with_empty_locked_whitelist() { + should_disallow_installation_of_contract_with_empty_locked_whitelist_with_holder_mode( + NFTHolderMode::Accounts + ); + should_disallow_installation_of_contract_with_empty_locked_whitelist_with_holder_mode( + NFTHolderMode::Contracts + ); + should_disallow_installation_of_contract_with_empty_locked_whitelist_with_holder_mode( + NFTHolderMode::Mixed + ); +} + +#[test] +fn should_disallow_installation_with_zero_issuance() { + let env = odra_test::env(); + let args = default_args_builder().total_token_supply(0).build(); + assert_eq!( + Cep78HostRef::try_deploy(&env, args).err(), + Some(CEP78Error::CannotInstallWithZeroSupply.into()), + "cannot install when issuance is equal 0", + ); +} + +#[test] +fn should_disallow_installation_with_supply_exceeding_hard_cap() { + let env = odra_test::env(); + let args = default_args_builder() + .total_token_supply(1_000_001u64) + .build(); + assert_eq!( + Cep78HostRef::try_deploy(&env, args).err(), + Some(CEP78Error::ExceededMaxTotalSupply.into()), + "cannot install when issuance is more than 1_000_000", + ); +} + +#[test] +fn should_prevent_installation_with_ownership_and_minting_modality_conflict() { + let env = odra_test::env(); + let args = default_args_builder() + .minting_mode(MintingMode::Installer) + .ownership_mode(OwnershipMode::Minter) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .build(); + assert_eq!( + Cep78HostRef::try_deploy(&env, args).err(), + Some(CEP78Error::InvalidReportingMode.into()), + "cannot install when Ownership::Minter and MintingMode::Installer", + ); +} + +#[test] +fn should_prevent_installation_with_ownership_minter_and_owner_reverse_lookup_mode_transfer_only() { + let env = odra_test::env(); + let args = default_args_builder() + .minting_mode(MintingMode::Installer) + .ownership_mode(OwnershipMode::Minter) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::TransfersOnly) + .build(); + assert_eq!( + Cep78HostRef::try_deploy(&env, args).err(), + Some(CEP78Error::OwnerReverseLookupModeNotTransferable.into()), + "cannot install when Ownership::Minter and OwnerReverseLookupMode::TransfersOnly", + ); +} + +#[test] +fn should_prevent_installation_with_ownership_assigned_and_owner_reverse_lookup_mode_transfer_only() +{ + let env = odra_test::env(); + let args = default_args_builder() + .minting_mode(MintingMode::Installer) + .ownership_mode(OwnershipMode::Assigned) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::TransfersOnly) + .build(); + assert_eq!( + Cep78HostRef::try_deploy(&env, args).err(), + Some(CEP78Error::OwnerReverseLookupModeNotTransferable.into()), + "cannot install when Ownership::Minter and OwnerReverseLookupMode::TransfersOnly", + ); +} + +#[test] +fn should_allow_installation_with_ownership_transferable_and_owner_reverse_lookup_mode_transfer_only( +) { + let env = odra_test::env(); + let args = default_args_builder() + .minting_mode(MintingMode::Installer) + .ownership_mode(OwnershipMode::Transferable) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::TransfersOnly) + .build(); + assert_eq!(Cep78HostRef::try_deploy(&env, args).err(), None); +} diff --git a/modules/src/cep78/tests/metadata.rs b/modules/src/cep78/tests/metadata.rs new file mode 100644 index 00000000..b2e93853 --- /dev/null +++ b/modules/src/cep78/tests/metadata.rs @@ -0,0 +1,486 @@ +use odra::{ + args::Maybe, + host::{Deployer, HostRef, NoArgs} +}; + +use crate::cep78::{ + error::CEP78Error, + events::MetadataUpdated, + modalities::{ + EventsMode, MetadataMutability, MintingMode, NFTHolderMode, NFTIdentifierMode, + NFTMetadataKind, OwnershipMode, TokenIdentifier, WhitelistMode + }, + tests::{ + utils::{ + MALFORMED_META_DATA, TEST_PRETTY_CEP78_METADATA, TEST_PRETTY_UPDATED_CEP78_METADATA + }, + TEST_CUSTOM_METADATA, TEST_CUSTOM_METADATA_SCHEMA, TEST_CUSTOM_UPDATED_METADATA, + TOKEN_HASH + }, + token::Cep78HostRef, + utils::MockContractHostRef +}; + +use super::{ + default_args_builder, + utils::{self, TEST_PRETTY_721_META_DATA, TEST_PRETTY_UPDATED_721_META_DATA} +}; + +#[test] +fn should_prevent_update_in_immutable_mode() { + let env = odra_test::env(); + let args = default_args_builder() + .nft_metadata_kind(NFTMetadataKind::NFT721) + .identifier_mode(NFTIdentifierMode::Hash) + .metadata_mutability(MetadataMutability::Immutable) + .ownership_mode(OwnershipMode::Transferable) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + contract.mint( + env.get_account(0), + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let blake2b_hash = utils::create_blake2b_hash(TEST_PRETTY_721_META_DATA); + let token_hash = base16::encode_lower(&blake2b_hash); + + assert_eq!( + contract.try_set_token_metadata( + Maybe::None, + Maybe::Some(token_hash), + TEST_PRETTY_UPDATED_721_META_DATA.to_string() + ), + Err(CEP78Error::ForbiddenMetadataUpdate.into()) + ); +} + +#[test] +fn should_allow_install_with_hash_identifier_in_mutable_mode() { + let env = odra_test::env(); + let args = default_args_builder() + .nft_metadata_kind(NFTMetadataKind::NFT721) + .identifier_mode(NFTIdentifierMode::Hash) + .metadata_mutability(MetadataMutability::Mutable) + .build(); + assert!(Cep78HostRef::try_deploy(&env, args).is_ok()); +} + +#[test] +fn should_prevent_update_for_invalid_metadata() { + let env = odra_test::env(); + let args = default_args_builder() + .nft_metadata_kind(NFTMetadataKind::NFT721) + .identifier_mode(NFTIdentifierMode::Ordinal) + .metadata_mutability(MetadataMutability::Mutable) + .ownership_mode(OwnershipMode::Transferable) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + contract.mint( + env.get_account(0), + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let original_metadata = + contract.get_metadata_by_kind(NFTMetadataKind::NFT721, Maybe::Some(0u64), Maybe::None); + assert_eq!(TEST_PRETTY_721_META_DATA, original_metadata); + + assert_eq!( + contract.try_set_token_metadata( + Maybe::Some(0u64), + Maybe::None, + MALFORMED_META_DATA.to_string() + ), + Err(CEP78Error::FailedToParse721Metadata.into()) + ); +} + +#[test] +fn should_prevent_metadata_update_by_non_owner_key() { + let env = odra_test::env(); + let args = default_args_builder() + .nft_metadata_kind(NFTMetadataKind::NFT721) + .identifier_mode(NFTIdentifierMode::Ordinal) + .metadata_mutability(MetadataMutability::Mutable) + .ownership_mode(OwnershipMode::Transferable) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = *contract.address(); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let original_metadata = + contract.get_metadata_by_kind(NFTMetadataKind::NFT721, Maybe::Some(0u64), Maybe::None); + assert_eq!(TEST_PRETTY_721_META_DATA, original_metadata); + + let actual_token_owner = contract.owner_of(Maybe::Some(0u64), Maybe::None); + assert_eq!(actual_token_owner, token_owner); + + assert_eq!( + contract.try_set_token_metadata( + Maybe::Some(0u64), + Maybe::None, + TEST_PRETTY_UPDATED_721_META_DATA.to_string() + ), + Err(CEP78Error::InvalidTokenOwner.into()) + ); +} + +fn should_allow_update_for_valid_metadata_based_on_kind( + nft_metadata_kind: NFTMetadataKind, + identifier_mode: NFTIdentifierMode +) { + let env = odra_test::env(); + let json_schema = + serde_json::to_string(&*TEST_CUSTOM_METADATA_SCHEMA).expect("must convert to json schema"); + let args = default_args_builder() + .nft_metadata_kind(nft_metadata_kind.clone()) + .identifier_mode(identifier_mode) + .metadata_mutability(MetadataMutability::Mutable) + .ownership_mode(OwnershipMode::Transferable) + .json_schema(json_schema) + .events_mode(EventsMode::CES) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = env.get_account(0); + + let custom_metadata = serde_json::to_string_pretty(&*TEST_CUSTOM_METADATA) + .expect("must convert to json metadata"); + + let original_metadata = match &nft_metadata_kind { + NFTMetadataKind::CEP78 => TEST_PRETTY_CEP78_METADATA, + NFTMetadataKind::NFT721 => TEST_PRETTY_721_META_DATA, + NFTMetadataKind::Raw => "", + NFTMetadataKind::CustomValidated => &custom_metadata + }; + + contract.mint(token_owner, original_metadata.to_string(), Maybe::None); + + let blake2b_hash = utils::create_blake2b_hash(original_metadata); + let token_hash = base16::encode_lower(&blake2b_hash); + let token_id = 0u64; + + let actual_metadata = match identifier_mode { + NFTIdentifierMode::Ordinal => contract.get_metadata_by_kind( + nft_metadata_kind.clone(), + Maybe::Some(token_id), + Maybe::None + ), + NFTIdentifierMode::Hash => contract.get_metadata_by_kind( + nft_metadata_kind.clone(), + Maybe::None, + Maybe::Some(token_hash.clone()) + ) + }; + + assert_eq!(actual_metadata, original_metadata.to_string()); + + let custom_updated_metadata = serde_json::to_string_pretty(&*TEST_CUSTOM_UPDATED_METADATA) + .expect("must convert to json metadata"); + + let updated_metadata = match &nft_metadata_kind { + NFTMetadataKind::CEP78 => TEST_PRETTY_UPDATED_CEP78_METADATA, + NFTMetadataKind::NFT721 => TEST_PRETTY_UPDATED_721_META_DATA, + NFTMetadataKind::Raw => "", + NFTMetadataKind::CustomValidated => &custom_updated_metadata + }; + + let update_result = match identifier_mode { + NFTIdentifierMode::Ordinal => contract.try_set_token_metadata( + Maybe::Some(token_id), + Maybe::None, + updated_metadata.to_string() + ), + NFTIdentifierMode::Hash => contract.try_set_token_metadata( + Maybe::None, + Maybe::Some(token_hash), + updated_metadata.to_string() + ) + }; + assert!(update_result.is_ok(), "failed to update metadata"); + + let blake2b_hash = utils::create_blake2b_hash(updated_metadata); + let token_hash = base16::encode_lower(&blake2b_hash); + + let actual_updated_metadata = match identifier_mode { + NFTIdentifierMode::Ordinal => { + contract.get_metadata_by_kind(nft_metadata_kind, Maybe::Some(token_id), Maybe::None) + } + NFTIdentifierMode::Hash => contract.get_metadata_by_kind( + nft_metadata_kind, + Maybe::None, + Maybe::Some(token_hash.clone()) + ) + }; + + assert_eq!(actual_updated_metadata, updated_metadata.to_string()); + + // Expect MetadataUpdated event. + let token_id = match identifier_mode { + NFTIdentifierMode::Ordinal => TokenIdentifier::Index(0), + NFTIdentifierMode::Hash => TokenIdentifier::Hash(token_hash) + } + .to_string(); + let expected_event = MetadataUpdated::new(token_id, updated_metadata.to_string()); + assert!(env.emitted_event(contract.address(), &expected_event)); +} + +#[test] +fn should_update_metadata_for_nft721_using_token_id() { + should_allow_update_for_valid_metadata_based_on_kind( + NFTMetadataKind::NFT721, + NFTIdentifierMode::Ordinal + ) +} + +#[test] +fn should_update_metadata_for_cep78_using_token_id() { + should_allow_update_for_valid_metadata_based_on_kind( + NFTMetadataKind::CEP78, + NFTIdentifierMode::Ordinal + ) +} + +#[test] +fn should_update_metadata_for_custom_validated_using_token_id() { + should_allow_update_for_valid_metadata_based_on_kind( + NFTMetadataKind::CustomValidated, + NFTIdentifierMode::Ordinal + ) +} + +#[test] +fn should_get_metadata_using_token_id() { + let env = odra_test::env(); + let mut minting_contract = MockContractHostRef::deploy(&env, NoArgs); + let contract_whitelist = vec![*minting_contract.address()]; + let args = default_args_builder() + .holder_mode(NFTHolderMode::Contracts) + .whitelist_mode(WhitelistMode::Locked) + .ownership_mode(OwnershipMode::Transferable) + .minting_mode(MintingMode::Acl) + .acl_white_list(contract_whitelist) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + minting_contract.set_address(contract.address()); + let token_id = 0u64; + + assert!( + contract.is_whitelisted(minting_contract.address()), + "acl whitelist is incorrectly set" + ); + + minting_contract.mint(TEST_PRETTY_721_META_DATA.to_string(), false); + + let minted_metadata = contract.metadata(Maybe::Some(token_id), Maybe::None); + assert_eq!(minted_metadata, TEST_PRETTY_721_META_DATA); +} + +#[test] +fn should_get_metadata_using_token_metadata_hash() { + let env = odra_test::env(); + let mut minting_contract = MockContractHostRef::deploy(&env, NoArgs); + let contract_whitelist = vec![*minting_contract.address()]; + let args = default_args_builder() + .identifier_mode(NFTIdentifierMode::Hash) + .holder_mode(NFTHolderMode::Contracts) + .whitelist_mode(WhitelistMode::Locked) + .ownership_mode(OwnershipMode::Transferable) + .metadata_mutability(MetadataMutability::Immutable) + .minting_mode(MintingMode::Acl) + .acl_white_list(contract_whitelist) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + minting_contract.set_address(contract.address()); + + assert!( + contract.is_whitelisted(minting_contract.address()), + "acl whitelist is incorrectly set" + ); + + minting_contract.mint(TEST_PRETTY_721_META_DATA.to_string(), false); + + let blake2b_hash = utils::create_blake2b_hash(TEST_PRETTY_721_META_DATA); + let token_hash = base16::encode_lower(&blake2b_hash); + + let minted_metadata = contract.metadata(Maybe::None, Maybe::Some(token_hash)); + assert_eq!(minted_metadata, TEST_PRETTY_721_META_DATA); +} + +#[test] +fn should_revert_minting_token_metadata_hash_twice() { + let env = odra_test::env(); + let mut minting_contract = MockContractHostRef::deploy(&env, NoArgs); + let contract_whitelist = vec![*minting_contract.address()]; + let args = default_args_builder() + .identifier_mode(NFTIdentifierMode::Hash) + .holder_mode(NFTHolderMode::Contracts) + .whitelist_mode(WhitelistMode::Locked) + .ownership_mode(OwnershipMode::Transferable) + .metadata_mutability(MetadataMutability::Immutable) + .minting_mode(MintingMode::Acl) + .acl_white_list(contract_whitelist) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + minting_contract.set_address(contract.address()); + assert!( + contract.is_whitelisted(minting_contract.address()), + "acl whitelist is incorrectly set" + ); + minting_contract.mint(TEST_PRETTY_721_META_DATA.to_string(), false); + + let blake2b_hash = utils::create_blake2b_hash(TEST_PRETTY_721_META_DATA); + let token_hash = base16::encode_lower(&blake2b_hash); + + let minted_metadata = contract.metadata(Maybe::None, Maybe::Some(token_hash)); + assert_eq!(minted_metadata, TEST_PRETTY_721_META_DATA); + + assert_eq!( + minting_contract.try_mint(TEST_PRETTY_721_META_DATA.to_string(), false), + Err(CEP78Error::DuplicateIdentifier.into()) + ); +} + +#[test] +fn should_get_metadata_using_custom_token_hash() { + let env = odra_test::env(); + let mut minting_contract = MockContractHostRef::deploy(&env, NoArgs); + let contract_whitelist = vec![*minting_contract.address()]; + let args = default_args_builder() + .identifier_mode(NFTIdentifierMode::Hash) + .holder_mode(NFTHolderMode::Contracts) + .whitelist_mode(WhitelistMode::Locked) + .ownership_mode(OwnershipMode::Transferable) + .metadata_mutability(MetadataMutability::Immutable) + .minting_mode(MintingMode::Acl) + .acl_white_list(contract_whitelist) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + minting_contract.set_address(contract.address()); + + assert!( + contract.is_whitelisted(minting_contract.address()), + "acl whitelist is incorrectly set" + ); + minting_contract.mint_with_hash( + TEST_PRETTY_721_META_DATA.to_string(), + TOKEN_HASH.to_string() + ); + + let minted_metadata: String = + contract.metadata(Maybe::None, Maybe::Some(TOKEN_HASH.to_string())); + assert_eq!(minted_metadata, TEST_PRETTY_721_META_DATA); +} + +#[test] +fn should_revert_minting_custom_token_hash_identifier_twice() { + let env = odra_test::env(); + let mut minting_contract = MockContractHostRef::deploy(&env, NoArgs); + let contract_whitelist = vec![*minting_contract.address()]; + let args = default_args_builder() + .identifier_mode(NFTIdentifierMode::Hash) + .holder_mode(NFTHolderMode::Contracts) + .whitelist_mode(WhitelistMode::Locked) + .ownership_mode(OwnershipMode::Transferable) + .metadata_mutability(MetadataMutability::Immutable) + .minting_mode(MintingMode::Acl) + .acl_white_list(contract_whitelist) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + minting_contract.set_address(contract.address()); + + assert!( + contract.is_whitelisted(minting_contract.address()), + "acl whitelist is incorrectly set" + ); + minting_contract.mint_with_hash( + TEST_PRETTY_721_META_DATA.to_string(), + TOKEN_HASH.to_string() + ); + + let minted_metadata: String = + contract.metadata(Maybe::None, Maybe::Some(TOKEN_HASH.to_string())); + assert_eq!(minted_metadata, TEST_PRETTY_721_META_DATA); + + assert_eq!( + minting_contract.try_mint_with_hash( + TEST_PRETTY_721_META_DATA.to_string(), + TOKEN_HASH.to_string() + ), + Err(CEP78Error::DuplicateIdentifier.into()) + ); +} + +#[test] +fn should_require_valid_json_schema_when_kind_is_custom_validated() { + let _env = odra_test::env(); + let _args = default_args_builder() + .identifier_mode(NFTIdentifierMode::Ordinal) + .ownership_mode(OwnershipMode::Transferable) + .nft_metadata_kind(NFTMetadataKind::CustomValidated) + .build(); + + /*let error = builder.get_error().expect("must have error"); + support::assert_expected_error(error, 68, "valid json_schema is required")*/ +} + +#[test] +fn should_require_json_schema_when_kind_is_custom_validated() { + let env = odra_test::env(); + let nft_metadata_kind = NFTMetadataKind::CustomValidated; + + let args = default_args_builder() + .identifier_mode(NFTIdentifierMode::Ordinal) + .ownership_mode(OwnershipMode::Transferable) + .metadata_mutability(MetadataMutability::Mutable) + .nft_metadata_kind(nft_metadata_kind) + .json_schema("".to_string()) + .build(); + + assert_eq!( + Cep78HostRef::try_deploy(&env, args).err(), + Some(CEP78Error::MissingJsonSchema.into()), + "should fail execution since json_schema is required" + ); +} + +fn should_not_require_json_schema_when_kind_is(nft_metadata_kind: NFTMetadataKind) { + let env = odra_test::env(); + let args = default_args_builder() + .identifier_mode(NFTIdentifierMode::Ordinal) + .ownership_mode(OwnershipMode::Transferable) + .metadata_mutability(MetadataMutability::Mutable) + .nft_metadata_kind(nft_metadata_kind.clone()) + .json_schema("".to_string()) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + let original_metadata = match &nft_metadata_kind { + NFTMetadataKind::CEP78 => TEST_PRETTY_CEP78_METADATA, + NFTMetadataKind::NFT721 => TEST_PRETTY_721_META_DATA, + NFTMetadataKind::Raw => "", + _ => panic!( + "NFTMetadataKind {:?} not supported without json_schema", + nft_metadata_kind + ) + }; + + assert!(contract + .try_mint( + env.get_account(0), + original_metadata.to_string(), + Maybe::None + ) + .is_ok()); +} + +#[test] +fn should_not_require_json_schema_when_kind_is_not_custom_validated() { + should_not_require_json_schema_when_kind_is(NFTMetadataKind::Raw); + should_not_require_json_schema_when_kind_is(NFTMetadataKind::CEP78); + should_not_require_json_schema_when_kind_is(NFTMetadataKind::NFT721); +} diff --git a/modules/src/cep78/tests/mint.rs b/modules/src/cep78/tests/mint.rs new file mode 100644 index 00000000..ee581c09 --- /dev/null +++ b/modules/src/cep78/tests/mint.rs @@ -0,0 +1,818 @@ +use odra::{ + args::Maybe, + host::{Deployer, HostEnv, HostRef, NoArgs} +}; +use serde::{Deserialize, Serialize}; + +use crate::cep78::{ + error::CEP78Error, + events::{ApprovalForAll, Mint, RevokedForAll}, + modalities::{ + EventsMode, MetadataMutability, MintingMode, NFTHolderMode, NFTIdentifierMode, + NFTMetadataKind, OwnerReverseLookupMode, OwnershipMode, TokenIdentifier, WhitelistMode + }, + tests::{ + utils::{ + self, MALFORMED_META_DATA, TEST_COMPACT_META_DATA, TEST_PRETTY_CEP78_METADATA, + TEST_PRETTY_UPDATED_CEP78_METADATA + }, + TEST_CUSTOM_METADATA, TEST_CUSTOM_METADATA_SCHEMA + }, + token::Cep78HostRef, + utils::MockContractHostRef +}; + +use super::{default_args_builder, utils::TEST_PRETTY_721_META_DATA}; + +#[derive(Serialize, Deserialize, Debug)] +struct Metadata { + name: String, + symbol: String, + token_uri: String +} + +fn default_token() -> (Cep78HostRef, HostEnv) { + let env = odra_test::env(); + let args = default_args_builder() + .total_token_supply(2u64) + .ownership_mode(OwnershipMode::Transferable) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .build(); + (Cep78HostRef::deploy(&env, args), env) +} + +#[test] +fn should_disallow_minting_when_allow_minting_is_set_to_false() { + let env = odra_test::env(); + let args = default_args_builder() + .nft_metadata_kind(NFTMetadataKind::NFT721) + .allow_minting(false) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + assert_eq!( + contract.try_mint( + env.get_account(0), + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ), + Err(CEP78Error::MintingIsPaused.into()), + "should now allow minting when minting is disabled", + ); +} + +#[test] +#[ignore = "Odra uses proxy pattern for contract calls, so this test is not applicable"] +fn entry_points_with_ret_should_return_correct_value() {} + +#[test] +fn should_mint() { + let env = odra_test::env(); + let args = default_args_builder() + .nft_metadata_kind(NFTMetadataKind::CEP78) + .events_mode(EventsMode::CES) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + let token_owner = env.get_account(0); + assert!(contract + .try_mint( + token_owner, + TEST_PRETTY_CEP78_METADATA.to_string(), + Maybe::None + ) + .is_ok()); + + // Expect Mint event. + let expected_event = Mint::new( + token_owner, + TokenIdentifier::Index(0).to_string(), + TEST_PRETTY_CEP78_METADATA.to_string() + ); + assert!(env.emitted_event(contract.address(), &expected_event)); +} + +#[test] +#[ignore = "Reverse lookup is not implemented yet"] +fn mint_should_return_dictionary_key_to_callers_owned_tokens() { + let env = odra_test::env(); + + let args = default_args_builder() + .ownership_mode(OwnershipMode::Transferable) + .allow_minting(true) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .build(); + let contract = Cep78HostRef::deploy(&env, args); + let mut minting_contract = MockContractHostRef::deploy(&env, NoArgs); + minting_contract.set_address(contract.address()); + + assert!(minting_contract + .try_mint_for(env.get_account(0), TEST_PRETTY_721_META_DATA.to_string()) + .is_ok()); + + /* + let nft_receipt: String = + support::query_stored_value(&builder, nft_contract_key, vec![RECEIPT_NAME.to_string()]); + + let account_receipt = *account + .named_keys() + .get(&format!("{nft_receipt}_m_{PAGE_SIZE}_p_{}", 0)) + .expect("must have receipt"); + + let actual_page = builder + .query(None, account_receipt, &[]) + .expect("must have stored_value") + .as_cl_value() + .map(|page_cl_value| CLValue::into_t::>(page_cl_value.clone())) + .unwrap() + .unwrap(); + + let expected_page = { + let mut page = vec![false; PAGE_SIZE as usize]; + let _ = std::mem::replace(&mut page[0], true); + page + }; + + assert_eq!(actual_page, expected_page); + */ +} + +#[test] +fn mint_should_increment_number_of_minted_tokens_by_one_and_add_public_key_to_token_owners() { + let (mut contract, env) = default_token(); + let owner = env.get_account(0); + + // TODO: should register the owner first to create a page for the owner + contract.register_owner(Maybe::Some(owner)); + assert!(contract + .try_mint(owner, TEST_PRETTY_721_META_DATA.to_string(), Maybe::None) + .is_ok()); + + assert_eq!( + contract.get_number_of_minted_tokens(), + 1u64, + "number_of_minted_tokens initialized at installation should have incremented by one" + ); + + let actual_token_meta_data = + contract.get_metadata_by_kind(NFTMetadataKind::NFT721, Maybe::Some(0u64), Maybe::None); + assert_eq!(actual_token_meta_data, TEST_PRETTY_721_META_DATA); + + let actual_token_owner = contract.owner_of(Maybe::Some(0u64), Maybe::None); + assert_eq!(actual_token_owner, owner); + + // Reverse lookup not implemented yet + /* let token_page = support::get_token_page_by_id( + &builder, + &nft_contract_key, + &Key::Account(*DEFAULT_ACCOUNT_ADDR), + token_id, + ); + + assert!(token_page[0]);*/ + + // If total_token_supply is initialized to 1 the following test should fail. + // If we set total_token_supply > 1 it should pass + assert!(contract + .try_mint(owner, TEST_PRETTY_721_META_DATA.to_string(), Maybe::None) + .is_ok()); +} + +#[test] +fn should_set_meta_data() { + let (mut contract, env) = default_token(); + + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let actual_token_meta_data = + contract.get_metadata_by_kind(NFTMetadataKind::NFT721, Maybe::Some(0u64), Maybe::None); + assert_eq!(actual_token_meta_data, TEST_PRETTY_721_META_DATA); +} +#[test] +fn should_set_issuer() { + let (mut contract, env) = default_token(); + + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let token_id = 0u64; + let actual_token_issuer = contract.get_token_issuer(Maybe::Some(token_id), Maybe::None); + assert_eq!(actual_token_issuer, token_owner); +} + +#[test] +fn should_set_issuer_with_different_owner() { + let (mut contract, env) = default_token(); + + let token_issuer = env.get_account(0); + let token_owner = env.get_account(1); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let token_id = 0u64; + let actual_token_issuer = contract.get_token_issuer(Maybe::Some(token_id), Maybe::None); + assert_eq!(actual_token_issuer, token_issuer); +} + +#[test] +fn should_track_token_balance_by_owner() { + let (mut contract, env) = default_token(); + + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let actual_minter_balance = contract.balance_of(token_owner); + let expected_minter_balance = 1u64; + assert_eq!(actual_minter_balance, expected_minter_balance); +} + +#[test] +fn should_allow_public_minting_with_flag_set_to_true() { + let env = odra_test::env(); + let args = default_args_builder() + .minting_mode(MintingMode::Public) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + let account_1 = env.get_account(1); + let minting_mode = contract.get_minting_mode(); + + assert_eq!( + minting_mode, + MintingMode::Public, + "public minting should be set to true" + ); + + let metadata = TEST_PRETTY_721_META_DATA.to_string(); + env.set_caller(account_1); + contract.mint(account_1, metadata, Maybe::None); + + let token_id = 0u64; + let minter_account_hash = contract.owner_of(Maybe::Some(token_id), Maybe::None); + assert_eq!(account_1, minter_account_hash); +} + +#[test] +fn should_disallow_public_minting_with_flag_set_to_false() { + let env = odra_test::env(); + let args = default_args_builder() + .minting_mode(MintingMode::Installer) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + let account_1 = env.get_account(1); + let metadata = TEST_PRETTY_721_META_DATA.to_string(); + + let minting_mode = contract.get_minting_mode(); + assert_eq!( + minting_mode, + MintingMode::Installer, + "public minting should be set to false" + ); + + env.set_caller(account_1); + assert_eq!( + contract.try_mint(account_1, metadata, Maybe::None), + Err(CEP78Error::InvalidMinter.into()), + "should not allow minting when minting is disabled" + ); +} + +#[test] +fn should_allow_minting_for_different_public_key_with_minting_mode_set_to_public() { + let env = odra_test::env(); + let args = default_args_builder() + .minting_mode(MintingMode::Public) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + let account_1 = env.get_account(1); + let account_2 = env.get_account(2); + + let minting_mode = contract.get_minting_mode(); + assert_eq!( + minting_mode, + MintingMode::Public, + "public minting should be set to true" + ); + + let metadata = TEST_PRETTY_721_META_DATA.to_string(); + assert!(contract + .try_mint(account_1, metadata.clone(), Maybe::None) + .is_ok()); + assert!(contract.try_mint(account_2, metadata, Maybe::None).is_ok()); +} + +#[test] +fn should_set_approval_for_all() { + let env = odra_test::env(); + let args = default_args_builder() + .ownership_mode(OwnershipMode::Transferable) + .events_mode(EventsMode::CES) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + let owner = env.get_account(0); + contract.mint(owner, TEST_PRETTY_721_META_DATA.to_string(), Maybe::None); + + let operator = env.get_account(1); + contract.set_approval_for_all(true, operator); + + let is_operator = contract.is_approved_for_all(owner, operator); + assert!(is_operator, "expected operator to be approved for all"); + + // Expect ApprovalForAll event. + let expected_event = ApprovalForAll::new(owner, operator); + assert!(env.emitted_event(contract.address(), &expected_event)); + + // Test if two minted tokens are transferable by operator + let token_receiver = env.get_account(1); + contract.register_owner(Maybe::Some(token_receiver)); + + let token_id = 0u64; + // Transfer first minted token by operator + let result = contract.try_transfer(Maybe::Some(token_id), Maybe::None, owner, token_receiver); + assert!(result.is_ok()); + + let actual_token_owner = contract.owner_of(Maybe::Some(token_id), Maybe::None); + assert_eq!(actual_token_owner, token_receiver); + + // Second mint by owner + contract.mint(owner, TEST_PRETTY_721_META_DATA.to_string(), Maybe::None); + + let token_id = 1u64; + // Transfer second minted token by operator + let result = contract.try_transfer(Maybe::Some(token_id), Maybe::None, owner, token_receiver); + assert!(result.is_ok()); + + let actual_token_owner = contract.owner_of(Maybe::Some(token_id), Maybe::None); + assert_eq!(actual_token_owner, token_receiver); +} + +#[test] +fn should_revoke_approval_for_all() { + let env = odra_test::env(); + let args = default_args_builder() + .ownership_mode(OwnershipMode::Transferable) + .events_mode(EventsMode::CES) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + let owner = env.get_account(0); + contract.mint(owner, TEST_PRETTY_721_META_DATA.to_string(), Maybe::None); + + let operator = env.get_account(1); + assert!(contract.try_set_approval_for_all(true, operator).is_ok()); + + let is_operator = contract.is_approved_for_all(owner, operator); + assert!(is_operator, "expected operator to be approved for all"); + + // Expect ApprovalForAll event. + let expected_event = ApprovalForAll::new(owner, operator); + assert!(env.emitted_event(contract.address(), &expected_event),); + + assert!(contract.try_set_approval_for_all(false, operator).is_ok()); + + let is_operator = contract.is_approved_for_all(owner, operator); + assert!(!is_operator, "expected operator not to be approved for all"); + + // Expect RevokedForAll event. + let expected_event = RevokedForAll::new(owner, operator); + assert!(env.emitted_event(contract.address(), &expected_event)); +} + +#[test] +fn should_not_mint_with_invalid_nft721_metadata() { + let (mut contract, env) = default_token(); + assert_eq!( + contract.try_mint( + env.get_account(0), + MALFORMED_META_DATA.to_string(), + Maybe::None + ), + Err(CEP78Error::FailedToParse721Metadata.into()), + "should not mint with invalid metadata" + ); +} + +#[test] +fn should_mint_with_compactified_metadata() { + let (mut contract, env) = default_token(); + contract.register_owner(Maybe::Some(env.get_account(0))); + contract.mint( + env.get_account(0), + TEST_COMPACT_META_DATA.to_string(), + Maybe::None + ); + + let token_id = 0u64; + let actual_metadata = + contract.get_metadata_by_kind(NFTMetadataKind::NFT721, Maybe::Some(token_id), Maybe::None); + assert_eq!(TEST_PRETTY_721_META_DATA, actual_metadata); +} + +#[test] +fn should_mint_with_valid_cep78_metadata() { + let env = odra_test::env(); + let args = default_args_builder() + .nft_metadata_kind(NFTMetadataKind::CEP78) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + contract.mint( + env.get_account(0), + TEST_PRETTY_CEP78_METADATA.to_string(), + Maybe::None + ); + + let token_id = 0u64; + let actual_metadata = + contract.get_metadata_by_kind(NFTMetadataKind::CEP78, Maybe::Some(token_id), Maybe::None); + assert_eq!(TEST_PRETTY_CEP78_METADATA, actual_metadata) +} + +#[test] +fn should_mint_with_custom_metadata_validation() { + let env = odra_test::env(); + let custom_json_schema = + serde_json::to_string(&*TEST_CUSTOM_METADATA_SCHEMA).expect("must convert to json schema"); + let args = default_args_builder() + .nft_metadata_kind(NFTMetadataKind::CustomValidated) + .json_schema(custom_json_schema) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + let metadata = + serde_json::to_string(&*TEST_CUSTOM_METADATA).expect("must convert to json metadata"); + contract.mint(env.get_account(0), metadata, Maybe::None); + + let token_id = 0u64; + let actual_metadata = contract.get_metadata_by_kind( + NFTMetadataKind::CustomValidated, + Maybe::Some(token_id), + Maybe::None + ); + let pretty_custom_metadata = serde_json::to_string_pretty(&*TEST_CUSTOM_METADATA) + .expect("must convert to json metadata"); + assert_eq!(pretty_custom_metadata, actual_metadata) +} + +#[test] +fn should_mint_with_raw_metadata() { + let env = odra_test::env(); + let args = default_args_builder() + .nft_metadata_kind(NFTMetadataKind::Raw) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + contract.mint(env.get_account(0), "raw_string".to_string(), Maybe::None); + + let token_id = 0u64; + let actual_metadata = + contract.get_metadata_by_kind(NFTMetadataKind::Raw, Maybe::Some(token_id), Maybe::None); + assert_eq!("raw_string".to_string(), actual_metadata) +} + +#[test] +fn should_mint_with_hash_identifier_mode() { + let env = odra_test::env(); + let args = default_args_builder() + .identifier_mode(NFTIdentifierMode::Hash) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + contract.mint( + env.get_account(0), + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let _token_id_hash = + base16::encode_lower(&utils::create_blake2b_hash(TEST_PRETTY_721_META_DATA)); + + // TODO: Reverse lookup not implemented yet + /* + let token_page = get_token_page_by_hash( + &builder, + &nft_contract_key, + &Key::Account(*DEFAULT_ACCOUNT_ADDR), + token_id_hash, + ); + + assert!(token_page[0])*/ +} + +#[test] +fn should_fail_to_mint_when_immediate_caller_is_account_in_contract_mode() { + let env = odra_test::env(); + let args = default_args_builder() + .holder_mode(NFTHolderMode::Contracts) + .whitelist_mode(WhitelistMode::Unlocked) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + assert_eq!( + contract.try_mint( + env.get_account(0), + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ), + Err(CEP78Error::InvalidHolderMode.into()) + ); +} + +#[test] +fn should_approve_in_hash_identifier_mode() { + let env = odra_test::env(); + let args = default_args_builder() + .identifier_mode(NFTIdentifierMode::Hash) + .metadata_mutability(MetadataMutability::Immutable) + .ownership_mode(OwnershipMode::Transferable) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + contract.mint( + env.get_account(0), + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + let blake2b_hash = utils::create_blake2b_hash(TEST_PRETTY_721_META_DATA); + let token_hash = base16::encode_lower(&blake2b_hash); + let spender = env.get_account(1); + contract.approve(spender, Maybe::None, Maybe::Some(token_hash.clone())); + + let approved_account = contract.get_approved(Maybe::None, Maybe::Some(token_hash.clone())); + assert_eq!(approved_account, Some(spender)) +} + +#[test] +fn should_mint_without_returning_receipts_and_flat_gas_cost() { + let env = odra_test::env(); + let args = default_args_builder() + .identifier_mode(NFTIdentifierMode::Ordinal) + .nft_metadata_kind(NFTMetadataKind::Raw) + .events_mode(EventsMode::CES) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + contract.mint(env.get_account(0), "".to_string(), Maybe::None); + contract.mint(env.get_account(1), "".to_string(), Maybe::None); + contract.mint(env.get_account(2), "".to_string(), Maybe::None); + + let costs = utils::get_gas_cost_of(&env, "mint"); + + // In this case there is no first time allocation of a page. + // Therefore the second and first mints must have equivalent gas costs. + if let (Some(c1), Some(c2)) = (costs.get(1), costs.get(2)) { + assert_eq!(c1, c2); + } +} + +// A test to ensure that the page table allocation is preserved +// even if the "register_owner" is called twice. +#[test] +fn should_maintain_page_table_despite_invoking_register_owner() { + let env = odra_test::env(); + let args = default_args_builder() + .identifier_mode(NFTIdentifierMode::Ordinal) + .nft_metadata_kind(NFTMetadataKind::Raw) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint(token_owner, "".to_string(), Maybe::None); + + // TODO: page table is not implemented yet + // let actual_page_table = contract.get_page_table(); + // assert_eq!(actual_page_table.len(), 1); + + // The mint WASM will register the owner, now we re-invoke the same entry point + // and ensure that the page table doesn't mutate. + contract.register_owner(Maybe::Some(token_owner)); + + // let table_post_register = contract.get_page_table(); + // assert_eq!(actual_page_table, table_post_register) +} + +#[test] +fn should_prevent_mint_to_unregistered_owner() { + let env = odra_test::env(); + let args = default_args_builder() + .identifier_mode(NFTIdentifierMode::Ordinal) + .nft_metadata_kind(NFTMetadataKind::Raw) + .ownership_mode(OwnershipMode::Transferable) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + assert_eq!( + contract.try_mint(env.get_account(0), "".to_string(), Maybe::None), + Err(CEP78Error::UnregisteredOwnerInMint.into()) + ); +} + +#[test] +fn should_mint_with_two_required_metadata_kind() { + let env = odra_test::env(); + let args = default_args_builder() + .identifier_mode(NFTIdentifierMode::Ordinal) + .nft_metadata_kind(NFTMetadataKind::CEP78) + .ownership_mode(OwnershipMode::Transferable) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .additional_required_metadata(vec![NFTMetadataKind::Raw]) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_CEP78_METADATA.to_string(), + Maybe::None + ); + + let token_id = 0u64; + let meta_78 = + contract.get_metadata_by_kind(NFTMetadataKind::CEP78, Maybe::Some(token_id), Maybe::None); + let meta_raw = + contract.get_metadata_by_kind(NFTMetadataKind::Raw, Maybe::Some(token_id), Maybe::None); + + assert_eq!(meta_78, TEST_PRETTY_CEP78_METADATA); + assert_eq!(meta_raw, TEST_PRETTY_CEP78_METADATA); +} + +#[test] +fn should_mint_with_one_required_one_optional_metadata_kind_without_optional() { + let env = odra_test::env(); + let args = default_args_builder() + .identifier_mode(NFTIdentifierMode::Ordinal) + .nft_metadata_kind(NFTMetadataKind::CEP78) + .ownership_mode(OwnershipMode::Transferable) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .optional_metadata(vec![NFTMetadataKind::Raw]) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_CEP78_METADATA.to_string(), + Maybe::None + ); + + let token_id = 0u64; + let meta_78 = + contract.get_metadata_by_kind(NFTMetadataKind::CEP78, Maybe::Some(token_id), Maybe::None); + let meta_raw = + contract.get_metadata_by_kind(NFTMetadataKind::Raw, Maybe::Some(token_id), Maybe::None); + + assert_eq!(meta_78, TEST_PRETTY_CEP78_METADATA); + assert_eq!(meta_raw, TEST_PRETTY_CEP78_METADATA); + + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_CEP78_METADATA.to_string(), + Maybe::None + ); + + let token_id = 1u64; + let meta_78 = + contract.get_metadata_by_kind(NFTMetadataKind::CEP78, Maybe::Some(token_id), Maybe::None); + + assert_eq!(meta_78, TEST_PRETTY_CEP78_METADATA); +} + +#[test] +fn should_not_mint_with_missing_required_metadata() { + let env = odra_test::env(); + let args = default_args_builder() + .identifier_mode(NFTIdentifierMode::Ordinal) + .nft_metadata_kind(NFTMetadataKind::CEP78) + .ownership_mode(OwnershipMode::Transferable) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .additional_required_metadata(vec![NFTMetadataKind::NFT721]) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = env.get_account(0); + + contract.register_owner(Maybe::Some(token_owner)); + assert_eq!( + contract.try_mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ), + Err(CEP78Error::FailedToParseCep78Metadata.into()) + ); +} + +#[test] +fn should_mint_with_transfer_only_reporting() { + let env = odra_test::env(); + let args = default_args_builder() + .ownership_mode(OwnershipMode::Transferable) + .nft_metadata_kind(NFTMetadataKind::CEP78) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::TransfersOnly) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = env.get_account(0); + contract.mint( + token_owner, + TEST_PRETTY_CEP78_METADATA.to_string(), + Maybe::None + ); + + let actual_balance_after_mint = contract.balance_of(token_owner); + let expected_balance_after_mint = 1u64; + assert_eq!(actual_balance_after_mint, expected_balance_after_mint); +} + +#[test] +fn should_approve_all_in_hash_identifier_mode() { + let env = odra_test::env(); + let args = default_args_builder() + .identifier_mode(NFTIdentifierMode::Hash) + .ownership_mode(OwnershipMode::Transferable) + .nft_metadata_kind(NFTMetadataKind::CEP78) + .events_mode(EventsMode::CES) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = env.get_account(0); + let operator = env.get_account(1); + contract.mint( + token_owner, + TEST_PRETTY_CEP78_METADATA.to_string(), + Maybe::None + ); + contract.mint( + token_owner, + TEST_PRETTY_UPDATED_CEP78_METADATA.to_string(), + Maybe::None + ); + + contract.set_approval_for_all(true, operator); + + let is_operator = contract.is_approved_for_all(token_owner, operator); + assert!(is_operator, "expected operator to be approved for all"); + + // Expect ApprovalForAll event. + let expected_event = ApprovalForAll::new(token_owner, operator); + assert!( + env.emitted_event(contract.address(), &expected_event), + "Expected ApprovalForAll event." + ); +} + +#[test] +fn should_approve_all_with_flat_gas_cost() { + let env = odra_test::env(); + let args = default_args_builder() + .ownership_mode(OwnershipMode::Transferable) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = env.get_account(0); + let operator = env.get_account(1); + let operator1 = env.get_account(2); + let operator2 = env.get_account(3); + + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + contract.set_approval_for_all(true, operator); + + contract.set_approval_for_all(true, operator1); + let is_operator = contract.is_approved_for_all(token_owner, operator1); + assert!(is_operator, "expected operator to be approved for all"); + + contract.set_approval_for_all(true, operator2); + let is_also_operator = contract.is_approved_for_all(token_owner, operator2); + assert!( + is_also_operator, + "expected other operator to be approved for all" + ); + let costs = utils::get_gas_cost_of(&env, "set_approval_for_all"); + + // Operator approval should have flat gas costs. + // First call creates necessary named keys. + // Therefore the second and third set_approve_for_all must have equivalent gas costs. + assert_eq!(costs.get(1), costs.get(2)); +} diff --git a/modules/src/cep78/tests/mod.rs b/modules/src/cep78/tests/mod.rs new file mode 100644 index 00000000..50c3188d --- /dev/null +++ b/modules/src/cep78/tests/mod.rs @@ -0,0 +1,67 @@ +use alloc::collections::BTreeMap; +use once_cell::sync::Lazy; + +use super::{ + metadata::{CustomMetadataSchema, MetadataSchemaProperty}, + modalities::NFTMetadataKind, + utils::InitArgsBuilder +}; + +mod acl; +mod burn; +mod costs; +mod events; +mod installer; +mod metadata; +mod mint; +mod set_variables; +mod transfer; +mod utils; + +pub(super) const COLLECTION_NAME: &str = "CEP78-Test-Collection"; +pub(super) const COLLECTION_SYMBOL: &str = "CEP78"; +pub const TOKEN_HASH: &str = "token_hash"; + +pub(super) fn default_args_builder() -> InitArgsBuilder { + InitArgsBuilder::default() + .collection_name(COLLECTION_NAME.to_string()) + .collection_symbol(COLLECTION_SYMBOL.to_string()) + .total_token_supply(100u64) + .nft_metadata_kind(NFTMetadataKind::NFT721) +} + +pub(super) static TEST_CUSTOM_METADATA: Lazy> = Lazy::new(|| { + let mut attributes = BTreeMap::new(); + attributes.insert("deity_name".to_string(), "Baldur".to_string()); + attributes.insert("mythology".to_string(), "Nordic".to_string()); + attributes +}); + +pub(crate) static TEST_CUSTOM_UPDATED_METADATA: Lazy> = Lazy::new(|| { + let mut attributes = BTreeMap::new(); + attributes.insert("deity_name".to_string(), "Baldur".to_string()); + attributes.insert("mythology".to_string(), "Nordic".to_string()); + attributes.insert("enemy".to_string(), "Loki".to_string()); + attributes +}); + +pub(crate) static TEST_CUSTOM_METADATA_SCHEMA: Lazy = Lazy::new(|| { + let mut properties = BTreeMap::new(); + properties.insert( + "deity_name".to_string(), + MetadataSchemaProperty { + name: "deity_name".to_string(), + description: "The name of deity from a particular pantheon.".to_string(), + required: true + } + ); + properties.insert( + "mythology".to_string(), + MetadataSchemaProperty { + name: "mythology".to_string(), + description: "The mythology the deity belongs to.".to_string(), + required: true + } + ); + CustomMetadataSchema { properties } +}); diff --git a/modules/src/cep78/tests/set_variables.rs b/modules/src/cep78/tests/set_variables.rs new file mode 100644 index 00000000..b85c26cf --- /dev/null +++ b/modules/src/cep78/tests/set_variables.rs @@ -0,0 +1,64 @@ +use odra::{ + args::Maybe, + host::{Deployer, HostRef} +}; + +use crate::cep78::{ + error::CEP78Error, events::VariablesSet, modalities::EventsMode, tests::default_args_builder, + token::Cep78HostRef +}; + +#[test] +fn only_installer_should_be_able_to_toggle_allow_minting() { + let env = odra_test::env(); + let (installer, other_user) = (env.get_account(0), env.get_account(1)); + let args = default_args_builder() + .allow_minting(false) + .events_mode(EventsMode::CES) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + // Account other than installer account should not be able to change allow_minting + env.set_caller(other_user); + assert_eq!( + contract.try_set_variables(Maybe::Some(true), Maybe::None, Maybe::None), + Err(CEP78Error::InvalidAccount.into()) + ); + assert!(!contract.is_minting_allowed()); + + // Installer account should be able to change allow_minting + env.set_caller(installer); + assert_eq!( + contract.try_set_variables(Maybe::Some(true), Maybe::None, Maybe::None), + Ok(()) + ); + assert!(contract.is_minting_allowed()); + + // Expect VariablesSet event. + assert!(env.emitted_event(contract.address(), &VariablesSet {})); +} + +#[test] +#[ignore = "Acl package mode is true by default and immutable"] +fn installer_should_be_able_to_toggle_acl_package_mode() {} + +#[test] +#[ignore = "Package operator mode is true by default and immutable"] +fn installer_should_be_able_to_toggle_package_operator_mode() {} + +#[test] +fn installer_should_be_able_to_toggle_operator_burn_mode() { + let env = odra_test::env(); + let args = default_args_builder().events_mode(EventsMode::CES).build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + // Installer account should be able to change allow_minting + assert_eq!( + contract.try_set_variables(Maybe::None, Maybe::None, Maybe::Some(true)), + Ok(()) + ); + assert!(contract.is_operator_burn_mode()); + + // Expect VariablesSet event. + assert!(env.emitted(contract.address(), "VariablesSet")); +} diff --git a/modules/src/cep78/tests/transfer.rs b/modules/src/cep78/tests/transfer.rs new file mode 100644 index 00000000..73b268bf --- /dev/null +++ b/modules/src/cep78/tests/transfer.rs @@ -0,0 +1,1026 @@ +use odra::{ + args::Maybe, + host::{Deployer, HostEnv, HostRef, NoArgs}, + Address +}; + +use crate::cep78::{ + error::CEP78Error, + events::{Approval, ApprovalRevoked, Transfer}, + modalities::{ + EventsMode, MetadataMutability, MintingMode, NFTHolderMode, NFTIdentifierMode, + NFTMetadataKind, OwnerReverseLookupMode, OwnershipMode, TokenIdentifier, + TransferFilterContractResult, WhitelistMode + }, + tests::{default_args_builder, utils::TEST_PRETTY_721_META_DATA}, + token::Cep78HostRef, + utils::{MockContractHostRef, MockTransferFilterContractHostRef} +}; + +use super::utils; + +#[test] +fn should_disallow_transfer_with_minter_or_assigned_ownership_mode() { + let env = odra_test::env(); + let args = default_args_builder() + .ownership_mode(OwnershipMode::Assigned) + .minting_mode(MintingMode::Installer) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let actual_owner_balance = contract.balance_of(token_owner); + let expected_owner_balance = 1u64; + assert_eq!(actual_owner_balance, expected_owner_balance); + + let token_receiver = env.get_account(1); + contract.register_owner(Maybe::Some(token_receiver)); + + let token_id = 0u64; + assert_eq!( + contract.try_transfer( + Maybe::Some(token_id), + Maybe::None, + token_owner, + token_receiver + ), + Err(CEP78Error::InvalidOwnershipMode.into()) + ); +} + +#[test] +fn should_transfer_token_from_sender_to_receiver() { + let env = odra_test::env(); + let args = default_args_builder() + .ownership_mode(OwnershipMode::Transferable) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .events_mode(EventsMode::CES) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let actual_owner_balance = contract.balance_of(token_owner); + let expected_owner_balance = 1u64; + assert_eq!(actual_owner_balance, expected_owner_balance); + + let token_receiver = env.get_account(1); + contract.register_owner(Maybe::Some(token_receiver)); + + let token_id = 0u64; + contract.transfer( + Maybe::Some(token_id), + Maybe::None, + token_owner, + token_receiver + ); + + let actual_token_owner = contract.owner_of(Maybe::Some(token_id), Maybe::None); + assert_eq!(actual_token_owner, token_receiver); + + // TODO: not implemented yet + /*let token_receiver_page = support::get_token_page_by_id(&builder, &nft_contract_key, &token_receiver_key, token_id); + assert!(token_receiver_page[0]);*/ + + let actual_sender_balance = contract.balance_of(token_owner); + let expected_sender_balance = 0u64; + assert_eq!(actual_sender_balance, expected_sender_balance); + + let actual_receiver_balance = contract.balance_of(token_receiver); + let expected_receiver_balance = 1u64; + assert_eq!(actual_receiver_balance, expected_receiver_balance); + + // Expect Transfer event. + let expected_event = Transfer::new( + token_owner, + None, + token_receiver, + TokenIdentifier::Index(token_id).to_string() + ); + assert!(env.emitted_event(contract.address(), &expected_event)); +} + +fn approve_token_for_transfer_should_add_entry_to_approved_dictionary( + env: HostEnv, + operator: Option
+) { + let args = default_args_builder() + .ownership_mode(OwnershipMode::Transferable) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .events_mode(EventsMode::CES) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let spender = env.get_account(1); + let token_id = 0u64; + + if let Some(operator) = operator { + assert!(contract.try_set_approval_for_all(true, operator).is_ok()); + } + + let approving_account = match operator { + Some(operator) => operator, + None => token_owner + }; + env.set_caller(approving_account); + assert!(contract + .try_approve(spender, Maybe::Some(token_id), Maybe::None) + .is_ok()); + + let actual_approved_key = contract.get_approved(Maybe::Some(token_id), Maybe::None); + assert_eq!(actual_approved_key, Some(spender)); + + // Expect Approval event. + let expected_event = Approval::new( + token_owner, + spender, + TokenIdentifier::Index(token_id).to_string() + ); + assert!(env.emitted_event(contract.address(), &expected_event)); +} + +#[test] +fn approve_token_for_transfer_from_an_account_should_add_entry_to_approved_dictionary() { + let env = odra_test::env(); + approve_token_for_transfer_should_add_entry_to_approved_dictionary(env, None) +} + +#[test] +fn approve_token_for_transfer_from_an_operator_should_add_entry_to_approved_dictionary() { + let env = odra_test::env(); + let operator = env.get_account(10); + approve_token_for_transfer_should_add_entry_to_approved_dictionary(env, Some(operator)) +} + +fn revoke_token_for_transfer_should_remove_entry_to_approved_dictionary( + env: HostEnv, + operator: Option
+) { + let args = default_args_builder() + .ownership_mode(OwnershipMode::Transferable) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .events_mode(EventsMode::CES) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let spender = env.get_account(1); + let token_id = 0u64; + + if let Some(operator) = operator { + assert!(contract.try_set_approval_for_all(true, operator).is_ok()); + } + + let approving_account = match operator { + Some(operator) => operator, + None => token_owner + }; + env.set_caller(approving_account); + contract.approve(spender, Maybe::Some(token_id), Maybe::None); + + let actual_approved_key = contract.get_approved(Maybe::Some(token_id), Maybe::None); + assert_eq!(actual_approved_key, Some(spender)); + + env.set_caller(token_owner); + contract.revoke(Maybe::Some(token_id), Maybe::None); + + let actual_approved_key = contract.get_approved(Maybe::Some(token_id), Maybe::None); + assert_eq!(actual_approved_key, None); + + // Expect ApprovalRevoked event. + let expected_event = + ApprovalRevoked::new(token_owner, TokenIdentifier::Index(token_id).to_string()); + assert!(env.emitted_event(contract.address(), &expected_event)); +} + +#[test] +fn revoke_token_for_transfer_from_account_should_remove_entry_to_approved_dictionary() { + revoke_token_for_transfer_should_remove_entry_to_approved_dictionary(odra_test::env(), None) +} + +#[test] +fn revoke_token_for_transfer_from_operator_should_remove_entry_to_approved_dictionary() { + let env = odra_test::env(); + let operator = env.get_account(10); + revoke_token_for_transfer_should_remove_entry_to_approved_dictionary(env, Some(operator)) +} + +#[test] +fn should_disallow_approving_when_ownership_mode_is_minter_or_assigned() { + let env = odra_test::env(); + let args = default_args_builder() + .ownership_mode(OwnershipMode::Assigned) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .events_mode(EventsMode::CES) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let spender = env.get_account(1); + let token_id = 0u64; + + assert_eq!( + contract.try_approve(spender, Maybe::Some(token_id), Maybe::None), + Err(CEP78Error::InvalidOwnershipMode.into()) + ); +} + +fn should_be_able_to_transfer_token(env: HostEnv, operator: Option
) { + let args = default_args_builder() + .ownership_mode(OwnershipMode::Transferable) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .events_mode(EventsMode::CES) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + // Create a "to approve" spender account account and transfer funds + let spender = env.get_account(1); + let token_id = 0u64; + + if let Some(operator) = operator { + assert!(contract.try_set_approval_for_all(true, operator).is_ok()); + } + + let approving_account = match operator { + Some(operator) => operator, + None => token_owner + }; + env.set_caller(approving_account); + contract.approve(spender, Maybe::Some(token_id), Maybe::None); + + let actual_approved_key = contract.get_approved(Maybe::Some(token_id), Maybe::None); + assert_eq!(actual_approved_key, Some(spender)); + + // Create to_account and transfer minted token using spender + let to_account = env.get_account(2); + contract.register_owner(Maybe::Some(to_account)); + contract.transfer(Maybe::Some(token_id), Maybe::None, token_owner, to_account); + + let actual_approved_account_hash = contract.get_approved(Maybe::Some(token_id), Maybe::None); + assert_eq!( + actual_approved_account_hash, None, + "approved account should be set to none after a transfer" + ); +} + +#[test] +fn should_be_able_to_transfer_token_using_approved_account() { + should_be_able_to_transfer_token(odra_test::env(), None) +} + +#[test] +fn should_be_able_to_transfer_token_using_operator() { + let env = odra_test::env(); + let operator = env.get_account(11); + should_be_able_to_transfer_token(env, Some(operator)) +} + +#[test] +fn should_disallow_same_approved_account_to_transfer_token_twice() { + let env = odra_test::env(); + let args = default_args_builder() + .ownership_mode(OwnershipMode::Transferable) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .events_mode(EventsMode::CES) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let actual_owner_balance = contract.balance_of(token_owner); + let expected_owner_balance = 1u64; + assert_eq!(actual_owner_balance, expected_owner_balance); + + // Create a "to approve" spender account and transfer funds + let spender = env.get_account(1); + let token_id = 0u64; + + // Approve spender + contract.approve(spender, Maybe::Some(token_id), Maybe::None); + + let actual_approved_account = contract.get_approved(Maybe::Some(token_id), Maybe::None); + let expected_approved_account = Some(spender); + assert_eq!( + actual_approved_account, expected_approved_account, + "approved account should have been set in dictionary when approved" + ); + + // Create to_account and transfer minted token using spender + let to_account = env.get_account(2); + contract.register_owner(Maybe::Some(to_account)); + + env.set_caller(spender); + contract.transfer(Maybe::Some(token_id), Maybe::None, token_owner, to_account); + + // Create to_other_account and transfer minted token using spender + let to_other_account = env.get_account(3); + contract.register_owner(Maybe::Some(to_other_account)); + + env.set_caller(spender); + assert_eq!( + contract.try_transfer( + Maybe::Some(token_id), + Maybe::None, + to_account, + to_other_account + ), + Err(CEP78Error::InvalidTokenOwner.into()) + ); +} + +fn should_disallow_to_transfer_token_using_revoked_hash(env: HostEnv, operator: Option
) { + let args = default_args_builder() + .ownership_mode(OwnershipMode::Transferable) + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let actual_owner_balance = contract.balance_of(token_owner); + let expected_owner_balance = 1u64; + assert_eq!(actual_owner_balance, expected_owner_balance); + + // Create a "to approve" spender account and transfer funds + let spender = env.get_account(1); + let token_id = 0u64; + + if let Some(operator) = operator { + assert!(contract.try_set_approval_for_all(true, operator).is_ok()); + } + + let approving_account = match operator { + Some(operator) => operator, + None => token_owner + }; + env.set_caller(approving_account); + contract.approve(spender, Maybe::Some(token_id), Maybe::None); + + let actual_approved_account = contract.get_approved(Maybe::Some(token_id), Maybe::None); + let expected_approved_account = Some(spender); + assert_eq!( + actual_approved_account, expected_approved_account, + "approved account should have been set in dictionary when approved" + ); + + // Create to_account and transfer minted token using account + let to_account = env.get_account(2); + contract.register_owner(Maybe::Some(to_account)); + + // Revoke approval + contract.revoke(Maybe::Some(token_id), Maybe::None); + + env.set_caller(spender); + assert_eq!( + contract.try_transfer(Maybe::Some(token_id), Maybe::None, token_owner, to_account), + Err(CEP78Error::InvalidTokenOwner.into()) + ); + + let actual_approved_account_hash = contract.get_approved(Maybe::Some(token_id), Maybe::None); + assert_eq!( + actual_approved_account_hash, None, + "approved account should be unset after revoke and a failed transfer" + ); +} + +#[test] +fn should_disallow_to_transfer_token_using_revoked_account() { + should_disallow_to_transfer_token_using_revoked_hash(odra_test::env(), None) +} + +#[test] +fn should_disallow_to_transfer_token_using_revoked_operator() { + let env = odra_test::env(); + let operator = env.get_account(11); + should_disallow_to_transfer_token_using_revoked_hash(env, Some(operator)) +} + +#[test] +#[ignore = "Odra does not support deprecated arguments"] +fn should_be_able_to_approve_with_deprecated_operator_argument() {} + +#[test] +fn should_transfer_between_contract_to_account() { + let env = odra_test::env(); + let mut minting_contract = MockContractHostRef::deploy(&env, NoArgs); + let contract_whitelist = vec![*minting_contract.address()]; + let args = default_args_builder() + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .events_mode(EventsMode::CES) + .holder_mode(NFTHolderMode::Contracts) + .whitelist_mode(WhitelistMode::Locked) + .ownership_mode(OwnershipMode::Transferable) + .minting_mode(MintingMode::Acl) + .acl_white_list(contract_whitelist) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + minting_contract.set_address(contract.address()); + + assert!( + contract.is_whitelisted(minting_contract.address()), + "acl whitelist is incorrectly set" + ); + minting_contract.mint(TEST_PRETTY_721_META_DATA.to_string(), true); + + let token_id = 0u64; + let actual_token_owner = contract.owner_of(Maybe::Some(token_id), Maybe::None); + assert_eq!( + &actual_token_owner, + minting_contract.address(), + "token owner is not minting contract" + ); + + let receiver = env.get_account(0); + contract.register_owner(Maybe::Some(receiver)); + minting_contract.transfer(token_id, receiver); + + let updated_token_owner = contract.owner_of(Maybe::Some(token_id), Maybe::None); + assert_eq!(updated_token_owner, receiver); +} + +#[test] +fn should_prevent_transfer_when_caller_is_not_owner() { + let env = odra_test::env(); + let args = default_args_builder() + .ownership_mode(OwnershipMode::Transferable) + .holder_mode(NFTHolderMode::Accounts) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + let token_owner = env.get_account(0); + let unauthorized_user = env.get_account(10); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let token_id = 0u64; + let actual_token_owner = contract.owner_of(Maybe::Some(token_id), Maybe::None); + assert_eq!(token_owner, actual_token_owner); + + env.set_caller(unauthorized_user); + assert_eq!( + contract.try_transfer( + Maybe::Some(token_id), + Maybe::None, + token_owner, + unauthorized_user + ), + Err(CEP78Error::InvalidTokenOwner.into()) + ); +} + +#[test] +fn should_transfer_token_in_hash_identifier_mode() { + let env = odra_test::env(); + let args = default_args_builder() + .identifier_mode(NFTIdentifierMode::Hash) + .ownership_mode(OwnershipMode::Transferable) + .metadata_mutability(MetadataMutability::Immutable) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + + let token_owner = env.get_account(0); + let new_owner = env.get_account(1); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let blake2b_hash = utils::create_blake2b_hash(TEST_PRETTY_721_META_DATA); + let token_hash = base16::encode_lower(&blake2b_hash); + contract.register_owner(Maybe::Some(new_owner)); + assert!(contract + .try_transfer(Maybe::None, Maybe::Some(token_hash), token_owner, new_owner) + .is_ok()); +} + +#[test] +fn should_not_allow_non_approved_contract_to_transfer() { + let env = odra_test::env(); + let mut minting_contract = MockContractHostRef::deploy(&env, NoArgs); + let args = default_args_builder() + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .holder_mode(NFTHolderMode::Mixed) + .ownership_mode(OwnershipMode::Transferable) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + minting_contract.set_address(contract.address()); + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let token_receiver = env.get_account(1); + contract.register_owner(Maybe::Some(token_receiver)); + + let token_id = 0u64; + assert_eq!( + minting_contract.try_transfer_from(token_id, token_owner, token_receiver), + Err(CEP78Error::InvalidTokenOwner.into()) + ); + + contract.approve( + *minting_contract.address(), + Maybe::Some(token_id), + Maybe::None + ); + assert!(minting_contract + .try_transfer_from(token_id, token_owner, token_receiver) + .is_ok()); +} + +#[test] +fn transfer_should_correctly_track_page_table_entries() { + let env = odra_test::env(); + let args = default_args_builder() + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .nft_metadata_kind(NFTMetadataKind::Raw) + .ownership_mode(OwnershipMode::Transferable) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = env.get_account(0); + let new_owner = env.get_account(1); + + let number_of_tokens_pre_migration = 20usize; + for _ in 0..number_of_tokens_pre_migration { + contract.register_owner(Maybe::Some(token_owner)); + contract.mint(token_owner, "".to_string(), Maybe::None); + } + + contract.register_owner(Maybe::Some(new_owner)); + contract.transfer(Maybe::Some(11u64), Maybe::None, token_owner, new_owner); + + /* TODO: not implemented yet + let new_owner_page_table = support::get_dictionary_value_from_key::>( + &builder, + &nft_contract_key, + PAGE_TABLE, + &AccountHash::new(ACCOUNT_USER_1).to_string(), + ); + assert!(account_user_1_page_table[0])*/ +} + +#[test] +fn should_prevent_transfer_to_unregistered_owner() { + let env = odra_test::env(); + let args = default_args_builder() + .owner_reverse_lookup_mode(OwnerReverseLookupMode::Complete) + .nft_metadata_kind(NFTMetadataKind::Raw) + .ownership_mode(OwnershipMode::Transferable) + .identifier_mode(NFTIdentifierMode::Ordinal) + .metadata_mutability(MetadataMutability::Immutable) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint(token_owner, "".to_string(), Maybe::None); + + let _token_id = 0u64; + let _token_receiver = env.get_account(1); + /* TODO: not implemented yet + assert_eq!( + contract.try_transfer( + Maybe::Some(token_id), + Maybe::None, + token_owner, + token_receiver + ), + Err(CEP78Error::UnregisteredOwnerInTransfer.into()) + ); */ +} + +#[test] +fn should_transfer_token_from_sender_to_receiver_with_transfer_only_reporting() { + let env = odra_test::env(); + let args = default_args_builder() + .owner_reverse_lookup_mode(OwnerReverseLookupMode::TransfersOnly) + .ownership_mode(OwnershipMode::Transferable) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + env.set_caller(token_owner); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let actual_owner_balance = contract.balance_of(token_owner); + let expected_owner_balance = 1u64; + assert_eq!(actual_owner_balance, expected_owner_balance); + + let token_receiver = env.get_account(1); + env.set_caller(token_owner); + contract.register_owner(Maybe::Some(token_receiver)); + contract.transfer(Maybe::Some(0u64), Maybe::None, token_owner, token_receiver); + + let actual_token_owner = contract.owner_of(Maybe::Some(0u64), Maybe::None); + assert_eq!(actual_token_owner, token_receiver); + + /*let token_receiver_page = support::get_token_page_by_id( + &builder, + &nft_contract_key, + &Key::Account(token_receiver), + 0u64, + ); + assert!(token_receiver_page[0]);*/ + + let actual_sender_balance = contract.balance_of(token_owner); + let expected_sender_balance = 0u64; + assert_eq!(actual_sender_balance, expected_sender_balance); + + let actual_receiver_balance = contract.balance_of(token_receiver); + let expected_receiver_balance = 1u64; + assert_eq!(actual_receiver_balance, expected_receiver_balance); +} + +#[test] +fn disallow_owner_to_approve_itself() { + let env = odra_test::env(); + let args = default_args_builder() + .ownership_mode(OwnershipMode::Transferable) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + assert_eq!( + contract.try_approve(token_owner, Maybe::Some(0u64), Maybe::None), + Err(CEP78Error::InvalidAccount.into()) + ); +} + +#[test] +fn disallow_operator_to_approve_itself() { + let env = odra_test::env(); + let args = default_args_builder() + .ownership_mode(OwnershipMode::Transferable) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let token_id = 0u64; + let operator = env.get_account(1); + + assert!(contract.try_set_approval_for_all(true, operator).is_ok()); + env.set_caller(operator); + assert_eq!( + contract.try_approve(operator, Maybe::Some(token_id), Maybe::None), + Err(CEP78Error::InvalidAccount.into()) + ); +} + +#[test] +fn disallow_owner_to_approve_for_all_itself() { + let env = odra_test::env(); + let args = default_args_builder() + .ownership_mode(OwnershipMode::Transferable) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + assert_eq!( + contract.try_set_approval_for_all(true, token_owner), + Err(CEP78Error::InvalidAccount.into()) + ); +} + +#[test] +fn check_transfers_with_transfer_filter_contract_modes() { + let env = odra_test::env(); + + let mut transfer_filter_contract = MockTransferFilterContractHostRef::deploy(&env, NoArgs); + transfer_filter_contract.set_return_value(TransferFilterContractResult::DenyTransfer as u8); + let args = default_args_builder() + .ownership_mode(OwnershipMode::Transferable) + .transfer_filter_contract_contract_key(*transfer_filter_contract.address()) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let token_owner = env.get_account(0); + + for _i in 0..2 { + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + } + + let token_receiver = env.get_account(1); + contract.register_owner(Maybe::Some(token_receiver)); + + let token_id = 0u64; + assert_eq!( + contract.try_transfer( + Maybe::Some(token_id), + Maybe::None, + token_owner, + token_receiver + ), + Err(CEP78Error::TransferFilterContractDenied.into()) + ); + + transfer_filter_contract.set_return_value(TransferFilterContractResult::ProceedTransfer as u8); + assert!(contract + .try_transfer( + Maybe::Some(token_id), + Maybe::None, + token_owner, + token_receiver + ) + .is_ok()); + + assert_eq!( + contract.try_transfer( + Maybe::Some(token_id), + Maybe::None, + token_receiver, + token_owner + ), + Err(CEP78Error::InvalidTokenOwner.into()) + ); + + let token_id = 1u64; + assert!(contract + .try_transfer( + Maybe::Some(token_id), + Maybe::None, + token_owner, + token_receiver + ) + .is_ok()); +} + +#[test] +fn should_disallow_transfer_from_contract_with_package_operator_mode_without_operator() { + let env = odra_test::env(); + let mut minting_contract = MockContractHostRef::deploy(&env, NoArgs); + let args = default_args_builder() + .ownership_mode(OwnershipMode::Transferable) + .holder_mode(NFTHolderMode::Mixed) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + minting_contract.set_address(contract.address()); + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let token_receiver = env.get_account(1); + contract.register_owner(Maybe::Some(token_receiver)); + + let token_id = 0u64; + assert_eq!( + minting_contract.try_transfer_from(token_id, token_owner, token_receiver), + Err(CEP78Error::InvalidTokenOwner.into()) + ); +} + +#[test] +#[ignore = "Odra does not support package operator mode - is always on"] +fn should_disallow_transfer_from_contract_without_package_operator_mode_with_package_as_operator() { +} + +#[test] +fn should_allow_transfer_from_contract_with_package_operator_mode_with_operator() { + let env = odra_test::env(); + let mut minting_contract = MockContractHostRef::deploy(&env, NoArgs); + let args = default_args_builder() + .ownership_mode(OwnershipMode::Transferable) + .holder_mode(NFTHolderMode::Mixed) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + minting_contract.set_address(contract.address()); + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let token_receiver = env.get_account(1); + contract.register_owner(Maybe::Some(token_receiver)); + + let token_id = 0u64; + contract.set_approval_for_all(true, *minting_contract.address()); + assert!(minting_contract + .try_transfer_from(token_id, token_owner, token_receiver) + .is_ok()); + + let actual_token_owner = contract.owner_of(Maybe::Some(token_id), Maybe::None); + assert_eq!(actual_token_owner, token_receiver); +} + +#[test] +#[ignore = "Odra does not support package operator mode - is always on"] +fn should_disallow_package_operator_to_approve_without_package_operator_mode() {} + +#[test] +fn should_allow_package_operator_to_approve_with_package_operator_mode() { + let env = odra_test::env(); + let mut minting_contract = MockContractHostRef::deploy(&env, NoArgs); + let args = default_args_builder() + .ownership_mode(OwnershipMode::Transferable) + .holder_mode(NFTHolderMode::Mixed) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + minting_contract.set_address(contract.address()); + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let token_receiver = env.get_account(1); + contract.register_owner(Maybe::Some(token_receiver)); + contract.set_approval_for_all(true, *minting_contract.address()); + + let token_id = 0u64; + let spender = env.get_account(2); + minting_contract.approve(spender, token_id); + + env.set_caller(spender); + contract.transfer( + Maybe::Some(token_id), + Maybe::None, + token_owner, + token_receiver + ); + + let actual_token_owner = contract.owner_of(Maybe::Some(token_id), Maybe::None); + assert_eq!(actual_token_owner, token_receiver); +} + +#[test] +fn should_allow_account_to_approve_spender_with_package_operator() { + let env = odra_test::env(); + let minting_contract = MockContractHostRef::deploy(&env, NoArgs); + let args = default_args_builder() + .ownership_mode(OwnershipMode::Transferable) + .holder_mode(NFTHolderMode::Mixed) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let token_receiver = env.get_account(1); + contract.register_owner(Maybe::Some(token_receiver)); + contract.set_approval_for_all(true, *minting_contract.address()); + + let token_id = 0u64; + let spender = env.get_account(2); + contract.approve(spender, Maybe::Some(token_id), Maybe::None); + + env.set_caller(spender); + contract.transfer( + Maybe::Some(token_id), + Maybe::None, + token_owner, + token_receiver + ); + + let actual_token_owner = contract.owner_of(Maybe::Some(token_id), Maybe::None); + assert_eq!(actual_token_owner, token_receiver); +} + +#[test] +fn should_allow_package_operator_to_revoke_with_package_operator_mode() { + let env = odra_test::env(); + let mut minting_contract = MockContractHostRef::deploy(&env, NoArgs); + let args = default_args_builder() + .ownership_mode(OwnershipMode::Transferable) + .holder_mode(NFTHolderMode::Mixed) + .build(); + let mut contract = Cep78HostRef::deploy(&env, args); + minting_contract.set_address(contract.address()); + let token_owner = env.get_account(0); + contract.register_owner(Maybe::Some(token_owner)); + contract.mint( + token_owner, + TEST_PRETTY_721_META_DATA.to_string(), + Maybe::None + ); + + let token_receiver = env.get_account(1); + contract.register_owner(Maybe::Some(token_receiver)); + contract.set_approval_for_all(true, *minting_contract.address()); + + let token_id = 0u64; + let spender = env.get_account(2); + contract.approve(spender, Maybe::Some(token_id), Maybe::None); + + minting_contract.revoke(token_id); + env.set_caller(spender); + + assert_eq!( + contract.try_transfer( + Maybe::Some(token_id), + Maybe::None, + token_owner, + token_receiver + ), + Err(CEP78Error::InvalidTokenOwner.into()) + ); + + let actual_token_owner = contract.owner_of(Maybe::Some(token_id), Maybe::None); + assert_eq!(actual_token_owner, token_owner); +} diff --git a/modules/src/cep78/tests/utils.rs b/modules/src/cep78/tests/utils.rs new file mode 100644 index 00000000..f9a8c319 --- /dev/null +++ b/modules/src/cep78/tests/utils.rs @@ -0,0 +1,74 @@ +use blake2::{digest::VariableOutput, Blake2bVar}; +use odra::{ + casper_types::{BLAKE2B_DIGEST_LENGTH, U512}, + host::HostEnv, + prelude::*, + DeployReport +}; +use std::io::Write; + +pub const TEST_PRETTY_721_META_DATA: &str = r#"{ + "name": "John Doe", + "symbol": "abc", + "token_uri": "https://www.barfoo.com" +}"#; +pub const TEST_PRETTY_UPDATED_721_META_DATA: &str = r#"{ + "name": "John Doe", + "symbol": "abc", + "token_uri": "https://www.foobar.com" +}"#; +pub const TEST_PRETTY_CEP78_METADATA: &str = r#"{ + "name": "John Doe", + "token_uri": "https://www.barfoo.com", + "checksum": "940bffb3f2bba35f84313aa26da09ece3ad47045c6a1292c2bbd2df4ab1a55fb" +}"#; +pub const TEST_PRETTY_UPDATED_CEP78_METADATA: &str = r#"{ + "name": "John Doe", + "token_uri": "https://www.foobar.com", + "checksum": "fda4feaa137e83972db628e521c92159f5dc253da1565c9da697b8ad845a0788" +}"#; +pub const TEST_COMPACT_META_DATA: &str = + r#"{"name": "John Doe","symbol": "abc","token_uri": "https://www.barfoo.com"}"#; +pub const MALFORMED_META_DATA: &str = r#"{ + "name": "John Doe", + "symbol": abc, + "token_uri": "https://www.barfoo.com" +}"#; + +pub(crate) fn create_blake2b_hash>(data: T) -> [u8; BLAKE2B_DIGEST_LENGTH] { + let mut result = [0u8; 32]; + let mut hasher = ::new(32).expect("should create hasher"); + let _ = hasher.write(data.as_ref()); + hasher + .finalize_variable(&mut result) + .expect("should copy hash to the result array"); + result +} + +pub(crate) fn get_gas_cost_of(env: &HostEnv, entry_point: &str) -> Vec { + let gas_report = env.gas_report(); + gas_report + .into_iter() + .filter_map(|r| match r { + DeployReport::WasmDeploy { .. } => None, + DeployReport::ContractCall { gas, call_def, .. } => { + if call_def.entry_point() == entry_point { + Some(gas) + } else { + None + } + } + }) + .collect::>() +} + +pub(crate) fn get_deploy_gas_cost(env: &HostEnv) -> Vec { + let gas_report = env.gas_report(); + gas_report + .into_iter() + .filter_map(|r| match r { + DeployReport::WasmDeploy { gas, .. } => Some(gas), + DeployReport::ContractCall { .. } => None + }) + .collect::>() +} diff --git a/modules/src/cep78/token.rs b/modules/src/cep78/token.rs new file mode 100644 index 00000000..91c6db6d --- /dev/null +++ b/modules/src/cep78/token.rs @@ -0,0 +1,762 @@ +#![allow(clippy::too_many_arguments)] +use crate::single_value_storage; + +use super::{ + constants::TRANSFER_FILTER_CONTRACT, + data::CollectionData, + error::CEP78Error, + events::{ + Approval, ApprovalForAll, ApprovalRevoked, Burn, MetadataUpdated, Mint, RevokedForAll, + Transfer, VariablesSet + }, + metadata::Metadata, + modalities::{ + BurnMode, EventsMode, MetadataMutability, MintingMode, NFTHolderMode, NFTIdentifierMode, + NFTKind, NFTMetadataKind, OwnerReverseLookupMode, OwnershipMode, TokenIdentifier, + TransferFilterContractResult, WhitelistMode + }, + reverse_lookup::ReverseLookup, + settings::Settings, + whitelist::ACLWhitelist +}; +use odra::{ + args::Maybe, casper_types::bytesrepr::ToBytes, prelude::*, Address, OdraError, SubModule, + UnwrapOrRevert +}; + +type MintReceipt = (String, Address, String); +type TransferReceipt = (String, Address); + +single_value_storage!( + Cep78TransferFilterContract, + Address, + TRANSFER_FILTER_CONTRACT +); + +/// CEP-78 is a standard for non-fungible tokens (NFTs) on the Casper network. +/// It defines a set of interfaces that allow for the creation, management, and +/// transfer of NFTs. The standard is designed to be flexible and modular, allowing +/// developers to customize the behavior of their NFTs to suit their specific needs. +/// The CEP-78 standard is inspired by the ERC-721 standard for NFTs on the Ethereum network. +/// The CEP-78 standard is designed to be simple and easy to use, while still providing +/// powerful features for developers to build on. +/// +/// A list of mandatory init arguments: +/// - `collection_name`: The name of the NFT collection. +/// - `collection_symbol`: The symbol of the NFT collection. +/// - `total_token_supply`: The total number of tokens that can be minted in the collection. +/// - `ownership_mode`: The ownership mode of the collection. See [OwnershipMode] for more details. +/// - `nft_kind`: The kind of NFTs in the collection. See [NFTKind] for more details. +/// - `nft_identifier_mode`: The identifier mode of the NFTs in the collection. See [NFTIdentifierMode] for more details. +/// - `nft_metadata_kind`: The kind of metadata associated with the NFTs in the collection. See [NFTMetadataKind] for more details. +/// - `metadata_mutability`: The mutability of the metadata associated with the NFTs in the collection. See [MetadataMutability] for more details. +#[odra::module( + version = "1.5.1", + events = [Approval, ApprovalForAll, ApprovalRevoked, Burn, MetadataUpdated, Mint, RevokedForAll, Transfer, VariablesSet] +)] +pub struct Cep78 { + data: SubModule, + metadata: SubModule, + settings: SubModule, + whitelist: SubModule, + reverse_lookup: SubModule, + transfer_filter_contract: SubModule +} + +#[odra::module] +impl Cep78 { + /// Initializes the module. + pub fn init( + &mut self, + collection_name: String, + collection_symbol: String, + total_token_supply: u64, + ownership_mode: OwnershipMode, + nft_kind: NFTKind, + identifier_mode: NFTIdentifierMode, + nft_metadata_kind: NFTMetadataKind, + metadata_mutability: MetadataMutability, + receipt_name: String, + allow_minting: Maybe, + minting_mode: Maybe, + holder_mode: Maybe, + whitelist_mode: Maybe, + acl_whitelist: Maybe>, + json_schema: Maybe, + burn_mode: Maybe, + operator_burn_mode: Maybe, + owner_reverse_lookup_mode: Maybe, + events_mode: Maybe, + transfer_filter_contract_contract: Maybe
, + additional_required_metadata: Maybe>, + optional_metadata: Maybe> + ) { + let installer = self.caller(); + let minting_mode = minting_mode.unwrap_or_default(); + let owner_reverse_lookup_mode = owner_reverse_lookup_mode.unwrap_or_default(); + let acl_white_list = acl_whitelist.unwrap_or_default(); + let whitelist_mode = whitelist_mode.unwrap_or_default(); + let json_schema = json_schema.unwrap_or_default(); + let is_whitelist_empty = acl_white_list.is_empty(); + + // Revert if minting mode is not ACL and acl list is not empty + if MintingMode::Acl != minting_mode && !is_whitelist_empty { + self.revert(CEP78Error::InvalidMintingMode) + } + + // Revert if minting mode is ACL or holder_mode is contracts and acl list is locked and empty + if MintingMode::Acl == minting_mode + && is_whitelist_empty + && WhitelistMode::Locked == whitelist_mode + { + self.revert(CEP78Error::EmptyACLWhitelist) + } + + // NOTE: It is commented out to allow having mutable metadata with hash identifier. + // NOTE: It's left for future reference. + // if identifier_mode == NFTIdentifierMode::Hash + // && metadata_mutability == MetadataMutability::Mutable + // { + // self.revert(CEP78Error::InvalidMetadataMutability) + // } + + if ownership_mode == OwnershipMode::Minter + && minting_mode == MintingMode::Installer + && owner_reverse_lookup_mode == OwnerReverseLookupMode::Complete + { + self.revert(CEP78Error::InvalidReportingMode) + } + + // Check if schema is missing before checking its validity + if nft_metadata_kind == NFTMetadataKind::CustomValidated && json_schema.is_empty() { + self.revert(CEP78Error::MissingJsonSchema) + } + + // OwnerReverseLookup TransfersOnly mode should be Transferable + if OwnerReverseLookupMode::TransfersOnly == owner_reverse_lookup_mode + && OwnershipMode::Transferable != ownership_mode + { + self.revert(CEP78Error::OwnerReverseLookupModeNotTransferable) + } + + if ownership_mode != OwnershipMode::Transferable + && transfer_filter_contract_contract.is_some() + { + self.revert(CEP78Error::TransferFilterContractNeedsTransferableMode) + } + + self.data.init( + collection_name, + collection_symbol, + total_token_supply, + installer + ); + self.settings.init( + allow_minting.unwrap_or(true), + minting_mode, + ownership_mode, + nft_kind, + holder_mode.unwrap_or_default(), + burn_mode.unwrap_or_default(), + events_mode.unwrap_or_default(), + operator_burn_mode.unwrap_or_default() + ); + + self.reverse_lookup + .init(owner_reverse_lookup_mode, receipt_name); + + self.whitelist.init(acl_white_list.clone(), whitelist_mode); + + self.metadata.init( + nft_metadata_kind, + additional_required_metadata, + optional_metadata, + metadata_mutability, + identifier_mode, + json_schema + ); + + if let Maybe::Some(transfer_filter_contract_contract) = transfer_filter_contract_contract { + self.transfer_filter_contract + .set(transfer_filter_contract_contract); + } + } + + /// Exposes all variables that can be changed by managing account post + /// installation. Meant to be called by the managing account (`Installer`) + /// if a variable needs to be changed. + /// By switching `allow_minting` to false minting is paused. + pub fn set_variables( + &mut self, + allow_minting: Maybe, + acl_whitelist: Maybe>, + operator_burn_mode: Maybe + ) { + let installer = self.data.installer(); + self.ensure_caller(installer); + + if let Maybe::Some(allow_minting) = allow_minting { + self.settings.set_allow_minting(allow_minting); + } + + if let Maybe::Some(operator_burn_mode) = operator_burn_mode { + self.settings.set_operator_burn_mode(operator_burn_mode); + } + + self.whitelist.update(acl_whitelist); + self.emit_ces_event(VariablesSet::new()); + } + + /// Mints a new token with provided metadata. + /// Reverts with [CEP78Error::MintingIsPaused] error if `allow_minting` is false. + /// When a token is minted, the calling account is listed as its owner and the token is + /// automatically assigned an `u64` ID equal to the current `number_of_minted_tokens`. + /// Before minting, the token checks if `number_of_minted_tokens` + /// exceeds the `total_token_supply`. If so, it reverts the minting with an error + /// [CEP78Error::TokenSupplyDepleted]. The `mint` function also checks whether the calling account + /// is the managing account (the installer) If not, and if `public_minting` is set to + /// false, it reverts with the error [CEP78Error::InvalidAccount]. + /// After minting is successful the number_of_minted_tokens is incremented by one. + pub fn mint( + &mut self, + token_owner: Address, + token_meta_data: String, + token_hash: Maybe + ) -> MintReceipt { + if !self.settings.allow_minting() { + self.revert(CEP78Error::MintingIsPaused); + } + + let total_token_supply = self.data.total_token_supply(); + let minted_tokens_count = self.data.number_of_minted_tokens(); + + if minted_tokens_count >= total_token_supply { + self.revert(CEP78Error::TokenSupplyDepleted); + } + + let minting_mode = self.settings.minting_mode(); + let caller = self.verified_caller(); + + if MintingMode::Installer == minting_mode { + match caller { + Address::Account(_) => { + let installer_account = self.data.installer(); + if caller != installer_account { + self.revert(CEP78Error::InvalidMinter) + } + } + _ => self.revert(CEP78Error::InvalidKey) + } + } + + if MintingMode::Acl == minting_mode && !self.whitelist.is_whitelisted(&caller) { + match caller { + Address::Contract(_) => self.revert(CEP78Error::UnlistedContractHash), + Address::Account(_) => self.revert(CEP78Error::InvalidMinter) + } + } + + let identifier_mode = self.metadata.get_identifier_mode(); + let optional_token_hash: String = token_hash.unwrap_or_default(); + let token_identifier: TokenIdentifier = match identifier_mode { + NFTIdentifierMode::Ordinal => TokenIdentifier::Index(minted_tokens_count), + NFTIdentifierMode::Hash => TokenIdentifier::Hash(if optional_token_hash.is_empty() { + let hash = self.__env.hash(token_meta_data.clone()); + base16::encode_lower(&hash) + } else { + optional_token_hash + }) + }; + let token_id = token_identifier.to_string(); + + self.metadata.update_or_revert(&token_meta_data, &token_id); + + let token_owner = if self.is_transferable_or_assigned() { + token_owner + } else { + caller + }; + + self.data.set_owner(&token_id, token_owner); + self.data.set_issuer(&token_id, caller); + + if let NFTIdentifierMode::Hash = identifier_mode { + self.reverse_lookup + .insert_hash(minted_tokens_count, &token_identifier); + } + + self.data.increment_counter(&token_owner); + self.data.increment_number_of_minted_tokens(); + + self.emit_ces_event(Mint::new(token_owner, token_id.clone(), token_meta_data)); + + self.reverse_lookup + .on_mint(minted_tokens_count, token_owner, token_id) + } + + /// Burns the token with provided `token_id` argument, after which it is no + /// longer possible to transfer it. + /// Looks up the owner of the supplied token_id arg. If caller is not owner we revert with + /// error [CEP78Error::InvalidTokenOwner]. If the token id is invalid (e.g. out of bounds) it reverts + /// with error [CEP78Error::InvalidTokenIdentifier]. If the token is listed as already burnt we revert with + /// error [CEP78Error::PreviouslyBurntToken]. If not the token is then registered as burnt. + pub fn burn(&mut self, token_id: Maybe, token_hash: Maybe) { + self.ensure_burnable(); + + let token_identifier = self.token_identifier(token_id, token_hash); + let token_id = token_identifier.to_string(); + + let token_owner = self.owner_of_by_id(&token_id); + let caller = self.__env.caller(); + + let is_owner = token_owner == caller; + let is_operator = if !is_owner { + self.data.operator(token_owner, caller) + } else { + false + }; + + if !is_owner && !is_operator { + self.revert(CEP78Error::InvalidTokenOwner) + }; + + self.ensure_not_burned(&token_id); + self.data.mark_burnt(&token_id); + self.data.decrement_counter(&token_owner); + + self.emit_ces_event(Burn::new(token_owner, token_id, caller)); + } + + /// Transfers ownership of the token from one account to another. + /// It looks up the owner of the supplied token_id arg. Reverts if the token is already burnt, + /// `token_id` is invalid, or if caller is not owner nor an approved account nor operator. + /// If token id is invalid it reverts with error [CEP78Error::InvalidTokenIdentifier]. + pub fn transfer( + &mut self, + token_id: Maybe, + token_hash: Maybe, + source_key: Address, + target_key: Address + ) -> TransferReceipt { + self.ensure_not_minter_or_assigned(); + + let token_identifier = self.checked_token_identifier(token_id, token_hash); + let token_id = token_identifier.to_string(); + self.ensure_not_burned(&token_id); + self.ensure_owner(&token_id, &source_key); + + let caller = self.caller(); + let owner = self.owner_of_by_id(&token_id); + let is_owner = owner == caller; + + let is_approved = !is_owner + && match self.data.approved(&token_id) { + Some(maybe_approved) => caller == maybe_approved, + _ => false + }; + + let is_operator = if !is_owner && !is_approved { + self.data.operator(source_key, caller) + } else { + false + }; + + if let Some(filter_contract) = self.transfer_filter_contract.get() { + let result = TransferFilterContractContractRef::new(self.env(), filter_contract) + .can_transfer(source_key, target_key, token_identifier.clone()); + + if TransferFilterContractResult::DenyTransfer == result { + self.revert(CEP78Error::TransferFilterContractDenied); + } + } + + if !is_owner && !is_approved && !is_operator { + self.revert(CEP78Error::InvalidTokenOwner); + } + + match self.data.owner_of(&token_id) { + Some(token_actual_owner) => { + if token_actual_owner != source_key { + self.revert(CEP78Error::InvalidTokenOwner) + } + self.data.set_owner(&token_id, target_key); + } + None => self.revert(CEP78Error::MissingOwnerTokenIdentifierKey) + } + + self.data.decrement_counter(&source_key); + self.data.increment_counter(&target_key); + self.data.revoke(&token_id); + + let spender = if caller == owner { None } else { Some(caller) }; + self.emit_ces_event(Transfer::new(owner, spender, target_key, token_id)); + + self.reverse_lookup + .on_transfer(token_identifier, source_key, target_key) + } + + /// Approves another token holder (an approved account) to transfer tokens. It + /// reverts if token_id is invalid, if caller is not the owner nor operator, if token has already + /// been burnt, or if caller tries to approve themselves as an approved account. + pub fn approve(&mut self, spender: Address, token_id: Maybe, token_hash: Maybe) { + self.ensure_not_minter_or_assigned(); + + let caller = self.caller(); + let token_identifier = self.checked_token_identifier(token_id, token_hash); + let token_id = token_identifier.to_string(); + + let owner = self.owner_of_by_id(&token_id); + + let is_owner = caller == owner; + let is_operator = !is_owner && self.data.operator(owner, caller); + + if !is_owner && !is_operator { + self.revert(CEP78Error::InvalidTokenOwner); + } + + self.ensure_not_burned(&token_id); + + self.ensure_not_caller(spender); + self.data.approve(&token_id, spender); + self.emit_ces_event(Approval::new(owner, spender, token_id)); + } + + /// Revokes an approved account to transfer tokens. It reverts + /// if token_id is invalid, if caller is not the owner, if token has already + /// been burnt, if caller tries to approve itself. + pub fn revoke(&mut self, token_id: Maybe, token_hash: Maybe) { + self.ensure_not_minter_or_assigned(); + + let caller = self.caller(); + let token_identifier = self.checked_token_identifier(token_id, token_hash); + let token_id = token_identifier.to_string(); + + let owner = self.owner_of_by_id(&token_id); + let is_owner = caller == owner; + let is_operator = !is_owner && self.data.operator(owner, caller); + + if !is_owner && !is_operator { + self.revert(CEP78Error::InvalidTokenOwner); + } + + self.ensure_not_burned(&token_id); + self.data.revoke(&token_id); + + self.emit_ces_event(ApprovalRevoked::new(owner, token_id)); + } + + /// Approves all tokens owned by the caller and future to another token holder + /// (an operator) to transfer tokens. It reverts if token_id is invalid, if caller is not the + /// owner, if caller tries to approve itself as an operator. + pub fn set_approval_for_all(&mut self, approve_all: bool, operator: Address) { + self.ensure_not_minter_or_assigned(); + self.ensure_not_caller(operator); + + let caller = self.caller(); + self.data.set_operator(caller, operator, approve_all); + + if let EventsMode::CES = self.settings.events_mode() { + if approve_all { + self.__env.emit_event(ApprovalForAll::new(caller, operator)); + } else { + self.__env.emit_event(RevokedForAll::new(caller, operator)); + } + } + } + + /// Returns if an account is operator for a token owner + pub fn is_approved_for_all(&mut self, token_owner: Address, operator: Address) -> bool { + self.data.operator(token_owner, operator) + } + + /// Returns the token owner given a token_id. It reverts if token_id + /// is invalid. A burnt token still has an associated owner. + pub fn owner_of(&self, token_id: Maybe, token_hash: Maybe) -> Address { + let token_identifier = self.checked_token_identifier(token_id, token_hash); + self.owner_of_by_id(&token_identifier.to_string()) + } + + /// Returns the approved account (if any) associated with the provided token_id + /// Reverts if token has been burnt. + pub fn get_approved( + &mut self, + token_id: Maybe, + token_hash: Maybe + ) -> Option
{ + let token_identifier: TokenIdentifier = self.checked_token_identifier(token_id, token_hash); + let token_id = token_identifier.to_string(); + + self.ensure_not_burned(&token_id); + self.data.approved(&token_id) + } + + /// Returns the metadata associated with the provided token_id + pub fn metadata(&mut self, token_id: Maybe, token_hash: Maybe) -> String { + let token_identifier = self.checked_token_identifier(token_id, token_hash); + self.metadata.get_or_revert(&token_identifier) + } + + /// Updates the metadata if valid. + pub fn set_token_metadata( + &mut self, + token_id: Maybe, + token_hash: Maybe, + token_meta_data: String + ) { + self.metadata + .ensure_mutability(CEP78Error::ForbiddenMetadataUpdate); + + let token_identifier = self.checked_token_identifier(token_id, token_hash); + let token_id = token_identifier.to_string(); + self.ensure_caller_is_owner(&token_id); + self.metadata.update_or_revert(&token_meta_data, &token_id); + + self.emit_ces_event(MetadataUpdated::new(token_id, token_meta_data)); + } + + /// Returns number of owned tokens associated with the provided token holder + pub fn balance_of(&mut self, token_owner: Address) -> u64 { + self.data.token_count(&token_owner) + } + + /// This entrypoint allows users to register with a give CEP-78 instance, + /// allocating the necessary page table to enable the reverse lookup + /// functionality and allowing users to pay the upfront cost of allocation + /// resulting in more stable gas costs when minting and transferring + /// Note: This entrypoint MUST be invoked if the reverse lookup is enabled + /// in order to own NFTs. + pub fn register_owner(&mut self, token_owner: Maybe
) -> String { + let ownership_mode = self.ownership_mode(); + self.reverse_lookup + .register_owner(token_owner, ownership_mode) + } + + /* + Test only getters + */ + pub fn is_whitelisted(&self, address: &Address) -> bool { + self.whitelist.is_whitelisted(address) + } + + pub fn get_whitelist_mode(&self) -> WhitelistMode { + self.whitelist.get_mode() + } + + pub fn get_collection_name(&self) -> String { + self.data.collection_name() + } + + pub fn get_collection_symbol(&self) -> String { + self.data.collection_symbol() + } + + pub fn is_minting_allowed(&self) -> bool { + self.settings.allow_minting() + } + + pub fn is_operator_burn_mode(&self) -> bool { + self.settings.operator_burn_mode() + } + + pub fn get_total_supply(&self) -> u64 { + self.data.total_token_supply() + } + + pub fn get_minting_mode(&self) -> MintingMode { + self.settings.minting_mode() + } + + pub fn get_holder_mode(&self) -> NFTHolderMode { + self.settings.holder_mode() + } + + pub fn get_number_of_minted_tokens(&self) -> u64 { + self.data.number_of_minted_tokens() + } + + pub fn get_metadata_by_kind( + &self, + kind: NFTMetadataKind, + token_id: Maybe, + token_hash: Maybe + ) -> String { + let token_identifier = self.checked_token_identifier(token_id, token_hash); + self.metadata + .get_metadata_by_kind(token_identifier.to_string(), &kind) + } + + pub fn get_token_issuer(&self, token_id: Maybe, token_hash: Maybe) -> Address { + let token_identifier = self.checked_token_identifier(token_id, token_hash); + self.data.issuer(&token_identifier.to_string()) + } + + pub fn token_burned(&self, token_id: Maybe, token_hash: Maybe) -> bool { + let token_identifier = self.token_identifier(token_id, token_hash); + let token_id = token_identifier.to_string(); + self.is_token_burned(&token_id) + } +} + +impl Cep78 { + #[inline] + fn caller(&self) -> Address { + self.__env.caller() + } + + #[inline] + fn revert>(&self, e: E) -> ! { + self.__env.revert(e) + } + + #[inline] + fn is_minter_or_assigned(&self) -> bool { + matches!( + self.ownership_mode(), + OwnershipMode::Minter | OwnershipMode::Assigned + ) + } + + #[inline] + fn is_transferable_or_assigned(&self) -> bool { + matches!( + self.ownership_mode(), + OwnershipMode::Transferable | OwnershipMode::Assigned + ) + } + + #[inline] + fn ensure_not_minter_or_assigned(&self) { + if self.is_minter_or_assigned() { + self.revert(CEP78Error::InvalidOwnershipMode) + } + } + + #[inline] + fn token_identifier(&self, token_id: Maybe, token_hash: Maybe) -> TokenIdentifier { + let env = self.env(); + let identifier_mode: NFTIdentifierMode = self.metadata.get_identifier_mode(); + match identifier_mode { + NFTIdentifierMode::Ordinal => TokenIdentifier::Index(token_id.unwrap(&env)), + NFTIdentifierMode::Hash => TokenIdentifier::Hash(token_hash.unwrap(&env)) + } + } + + #[inline] + fn checked_token_identifier( + &self, + token_id: Maybe, + token_hash: Maybe + ) -> TokenIdentifier { + let identifier_mode: NFTIdentifierMode = self.metadata.get_identifier_mode(); + let token_identifier = match identifier_mode { + NFTIdentifierMode::Ordinal => TokenIdentifier::Index(token_id.unwrap(&self.__env)), + NFTIdentifierMode::Hash => TokenIdentifier::Hash(token_hash.unwrap(&self.__env)) + }; + + let number_of_minted_tokens = self.data.number_of_minted_tokens(); + if let NFTIdentifierMode::Ordinal = identifier_mode { + // Revert if token_id is out of bounds + if token_identifier.get_index().unwrap_or_revert(&self.__env) >= number_of_minted_tokens + { + self.revert(CEP78Error::InvalidTokenIdentifier); + } + } + token_identifier + } + + #[inline] + fn owner_of_by_id(&self, id: &str) -> Address { + match self.data.owner_of(id) { + Some(token_owner) => token_owner, + None => self + .env() + .revert(CEP78Error::MissingOwnerTokenIdentifierKey) + } + } + + #[inline] + fn is_token_burned(&self, token_id: &str) -> bool { + self.data.is_burnt(token_id) + } + + #[inline] + fn ensure_owner(&self, token_id: &str, address: &Address) { + let owner = self.owner_of_by_id(token_id); + if address != &owner { + self.revert(CEP78Error::InvalidAccount); + } + } + + #[inline] + fn ensure_caller_is_owner(&self, token_id: &str) { + let owner = self.owner_of_by_id(token_id); + if self.caller() != owner { + self.revert(CEP78Error::InvalidTokenOwner); + } + } + + #[inline] + fn ensure_not_burned(&self, token_id: &str) { + if self.is_token_burned(token_id) { + self.revert(CEP78Error::PreviouslyBurntToken); + } + } + + #[inline] + fn ensure_not_caller(&self, address: Address) { + if self.caller() == address { + self.revert(CEP78Error::InvalidAccount); + } + } + + #[inline] + fn ensure_caller(&self, address: Address) { + if self.caller() != address { + self.revert(CEP78Error::InvalidAccount); + } + } + + #[inline] + fn emit_ces_event(&self, event: T) { + let events_mode = self.settings.events_mode(); + if let EventsMode::CES = events_mode { + self.env().emit_event(event); + } + } + + #[inline] + fn ensure_burnable(&self) { + if let BurnMode::NonBurnable = self.settings.burn_mode() { + self.revert(CEP78Error::InvalidBurnMode) + } + } + + #[inline] + fn ownership_mode(&self) -> OwnershipMode { + self.settings.ownership_mode() + } + + #[inline] + fn verified_caller(&self) -> Address { + let holder_mode = self.settings.holder_mode(); + let caller = self.caller(); + + match (caller, holder_mode) { + (Address::Account(_), NFTHolderMode::Contracts) + | (Address::Contract(_), NFTHolderMode::Accounts) => { + self.revert(CEP78Error::InvalidHolderMode); + } + _ => caller + } + } +} + +#[odra::external_contract] +pub trait TransferFilterContract { + fn can_transfer( + &self, + source_key: Address, + target_key: Address, + token_id: TokenIdentifier + ) -> TransferFilterContractResult; +} diff --git a/modules/src/cep78/utils.rs b/modules/src/cep78/utils.rs new file mode 100644 index 00000000..9d563736 --- /dev/null +++ b/modules/src/cep78/utils.rs @@ -0,0 +1,332 @@ +#![allow(dead_code)] +#[cfg(not(target_arch = "wasm32"))] +use crate::cep78::{ + modalities::{ + BurnMode, EventsMode, MetadataMutability, MintingMode, NFTHolderMode, NFTIdentifierMode, + NFTKind, NFTMetadataKind, OwnerReverseLookupMode, OwnershipMode, WhitelistMode + }, + token::Cep78InitArgs +}; +use odra::{args::Maybe, prelude::*, Address, Var}; + +#[odra::module] +struct MockDummyContract; + +#[odra::module] +impl MockDummyContract {} + +#[odra::module] +pub struct MockTransferFilterContract { + value: Var +} + +#[odra::module] +impl MockTransferFilterContract { + pub fn set_return_value(&mut self, return_value: u8) { + self.value.set(return_value); + } + + pub fn can_transfer(&self) -> u8 { + self.value.get_or_default() + } +} + +#[odra::module] +struct MockContract { + nft_contract: Var
+} + +#[odra::module] +impl MockContract { + pub fn set_address(&mut self, nft_contract: &Address) { + self.nft_contract.set(*nft_contract); + } + + pub fn mint( + &mut self, + token_metadata: String, + is_reverse_lookup_enabled: bool + ) -> (String, Address, String) { + let nft_contract_address = self.nft_contract.get().unwrap(); + if is_reverse_lookup_enabled { + NftContractContractRef::new(self.env(), nft_contract_address) + .register_owner(Maybe::Some(self.env().self_address())); + } + + NftContractContractRef::new(self.env(), nft_contract_address).mint( + self.env().self_address(), + token_metadata, + Maybe::None + ) + } + + pub fn mint_with_hash( + &mut self, + token_metadata: String, + token_hash: String + ) -> (String, Address, String) { + let nft_contract_address = self.nft_contract.get().unwrap(); + NftContractContractRef::new(self.env(), nft_contract_address).mint( + self.env().self_address(), + token_metadata, + Maybe::Some(token_hash) + ) + } + + pub fn burn(&mut self, token_id: u64) { + let nft_contract_address = self.nft_contract.get().unwrap(); + NftContractContractRef::new(self.env(), nft_contract_address) + .burn(Maybe::Some(token_id), Maybe::None) + } + + pub fn mint_for( + &mut self, + token_owner: Address, + token_metadata: String + ) -> (String, Address, String) { + let nft_contract_address = self.nft_contract.get().unwrap(); + NftContractContractRef::new(self.env(), nft_contract_address).mint( + token_owner, + token_metadata, + Maybe::None + ) + } + + pub fn transfer(&mut self, token_id: u64, target: Address) -> (String, Address) { + let address = self.env().self_address(); + let nft_contract_address = self.nft_contract.get().unwrap(); + NftContractContractRef::new(self.env(), nft_contract_address).transfer( + Maybe::Some(token_id), + Maybe::None, + address, + target + ) + } + pub fn transfer_from( + &mut self, + token_id: u64, + source: Address, + target: Address + ) -> (String, Address) { + let nft_contract_address = self.nft_contract.get().unwrap(); + NftContractContractRef::new(self.env(), nft_contract_address).transfer( + Maybe::Some(token_id), + Maybe::None, + source, + target + ) + } + + pub fn approve(&mut self, spender: Address, token_id: u64) { + let nft_contract_address = self.nft_contract.get().unwrap(); + NftContractContractRef::new(self.env(), nft_contract_address).approve( + spender, + Maybe::Some(token_id), + Maybe::None + ) + } + + pub fn revoke(&mut self, token_id: u64) { + let nft_contract_address = self.nft_contract.get().unwrap(); + NftContractContractRef::new(self.env(), nft_contract_address) + .revoke(Maybe::Some(token_id), Maybe::None) + } +} + +#[odra::external_contract] +trait NftContract { + fn mint( + &mut self, + token_owner: Address, + token_meta_data: String, + token_hash: Maybe + ) -> (String, Address, String); + fn burn(&mut self, token_id: Maybe, token_hash: Maybe); + fn register_owner(&mut self, token_owner: Maybe
) -> String; + fn transfer( + &mut self, + token_id: Maybe, + token_hash: Maybe, + source_key: Address, + target_key: Address + ) -> (String, Address); + fn approve(&mut self, spender: Address, token_id: Maybe, token_hash: Maybe); + fn revoke(&mut self, token_id: Maybe, token_hash: Maybe); +} + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Default)] +pub struct InitArgsBuilder { + collection_name: String, + collection_symbol: String, + total_token_supply: u64, + allow_minting: Maybe, + minting_mode: Maybe, + ownership_mode: OwnershipMode, + nft_kind: NFTKind, + receipt_name: String, + holder_mode: Maybe, + whitelist_mode: Maybe, + acl_white_list: Maybe>, + json_schema: Maybe, + identifier_mode: NFTIdentifierMode, + burn_mode: Maybe, + operator_burn_mode: Maybe, + nft_metadata_kind: NFTMetadataKind, + metadata_mutability: MetadataMutability, + owner_reverse_lookup_mode: Maybe, + events_mode: Maybe, + transfer_filter_contract_contract_key: Maybe
, + additional_required_metadata: Maybe>, + optional_metadata: Maybe> +} + +#[cfg(not(target_arch = "wasm32"))] +impl InitArgsBuilder { + pub fn collection_name(mut self, collection_name: String) -> Self { + self.collection_name = collection_name; + self + } + + pub fn collection_symbol(mut self, collection_symbol: String) -> Self { + self.collection_symbol = collection_symbol; + self + } + + pub fn total_token_supply(mut self, total_token_supply: u64) -> Self { + self.total_token_supply = total_token_supply; + self + } + + pub fn allow_minting(mut self, allow_minting: bool) -> Self { + self.allow_minting = Maybe::Some(allow_minting); + self + } + + pub fn nft_kind(mut self, nft_kind: NFTKind) -> Self { + self.nft_kind = nft_kind; + self + } + + pub fn minting_mode(mut self, minting_mode: MintingMode) -> Self { + self.minting_mode = Maybe::Some(minting_mode); + self + } + + pub fn ownership_mode(mut self, ownership_mode: OwnershipMode) -> Self { + self.ownership_mode = ownership_mode; + self + } + + pub fn holder_mode(mut self, holder_mode: NFTHolderMode) -> Self { + self.holder_mode = Maybe::Some(holder_mode); + self + } + + pub fn whitelist_mode(mut self, whitelist_mode: WhitelistMode) -> Self { + self.whitelist_mode = Maybe::Some(whitelist_mode); + self + } + + pub fn acl_white_list(mut self, acl_white_list: Vec
) -> Self { + self.acl_white_list = Maybe::Some(acl_white_list); + self + } + + pub fn json_schema(mut self, json_schema: String) -> Self { + self.json_schema = Maybe::Some(json_schema); + self + } + + pub fn identifier_mode(mut self, identifier_mode: NFTIdentifierMode) -> Self { + self.identifier_mode = identifier_mode; + self + } + + pub fn receipt_name(mut self, receipt_name: String) -> Self { + self.receipt_name = receipt_name; + self + } + + pub fn burn_mode(mut self, burn_mode: BurnMode) -> Self { + self.burn_mode = Maybe::Some(burn_mode); + self + } + + pub fn operator_burn_mode(mut self, operator_burn_mode: bool) -> Self { + self.operator_burn_mode = Maybe::Some(operator_burn_mode); + self + } + + pub fn nft_metadata_kind(mut self, nft_metadata_kind: NFTMetadataKind) -> Self { + self.nft_metadata_kind = nft_metadata_kind; + self + } + + pub fn metadata_mutability(mut self, metadata_mutability: MetadataMutability) -> Self { + self.metadata_mutability = metadata_mutability; + self + } + + pub fn owner_reverse_lookup_mode( + mut self, + owner_reverse_lookup_mode: OwnerReverseLookupMode + ) -> Self { + self.owner_reverse_lookup_mode = Maybe::Some(owner_reverse_lookup_mode); + self + } + + pub fn events_mode(mut self, events_mode: EventsMode) -> Self { + self.events_mode = Maybe::Some(events_mode); + self + } + + pub fn transfer_filter_contract_contract_key( + mut self, + transfer_filter_contract_contract_key: Address + ) -> Self { + self.transfer_filter_contract_contract_key = + Maybe::Some(transfer_filter_contract_contract_key); + self + } + + pub fn additional_required_metadata( + mut self, + additional_required_metadata: Vec + ) -> Self { + self.additional_required_metadata = Maybe::Some(additional_required_metadata); + self + } + + pub fn optional_metadata(mut self, optional_metadata: Vec) -> Self { + self.optional_metadata = Maybe::Some(optional_metadata); + self + } + + pub fn build(self) -> Cep78InitArgs { + Cep78InitArgs { + collection_name: self.collection_name, + collection_symbol: self.collection_symbol, + total_token_supply: self.total_token_supply, + allow_minting: self.allow_minting, + minting_mode: self.minting_mode, + ownership_mode: self.ownership_mode, + nft_kind: self.nft_kind, + holder_mode: self.holder_mode, + whitelist_mode: self.whitelist_mode, + acl_whitelist: self.acl_white_list, + json_schema: self.json_schema, + receipt_name: self.receipt_name, + identifier_mode: self.identifier_mode, + burn_mode: self.burn_mode, + operator_burn_mode: self.operator_burn_mode, + nft_metadata_kind: self.nft_metadata_kind, + metadata_mutability: self.metadata_mutability, + owner_reverse_lookup_mode: self.owner_reverse_lookup_mode, + events_mode: self.events_mode, + transfer_filter_contract_contract: self.transfer_filter_contract_contract_key, + additional_required_metadata: self.additional_required_metadata, + optional_metadata: self.optional_metadata + } + } +} diff --git a/modules/src/cep78/whitelist.rs b/modules/src/cep78/whitelist.rs new file mode 100644 index 00000000..e85b4c25 --- /dev/null +++ b/modules/src/cep78/whitelist.rs @@ -0,0 +1,76 @@ +use odra::{args::Maybe, prelude::*, Address, SubModule}; + +use crate::{key_value_storage, single_value_storage}; + +use super::{ + constants::{ACL_PACKAGE_MODE, ACL_WHITELIST, WHITELIST_MODE}, + error::CEP78Error, + modalities::WhitelistMode +}; + +single_value_storage!( + Cep78WhitelistMode, + WhitelistMode, + WHITELIST_MODE, + CEP78Error::InvalidACLPackageMode +); +single_value_storage!( + Cep78PackageMode, + bool, + ACL_PACKAGE_MODE, + CEP78Error::InvalidACLPackageMode +); +key_value_storage!(Cep78ACLWhitelist, ACL_WHITELIST, bool); +impl Cep78ACLWhitelist { + pub fn clear(&self) { + self.env().remove_dictionary(ACL_WHITELIST); + } +} + +#[odra::module] +pub struct ACLWhitelist { + whitelist: SubModule, + mode: SubModule, + package_mode: SubModule +} + +impl ACLWhitelist { + pub fn init(&mut self, addresses: Vec
, mode: WhitelistMode) { + for address in addresses.iter() { + self.whitelist.set(&address.to_string(), true); + } + self.mode.set(mode); + // Odra does not support version mode. + self.package_mode.set(true); + } + + #[inline] + pub fn get_mode(&self) -> WhitelistMode { + self.mode.get() + } + + #[inline] + pub fn is_whitelisted(&self, address: &Address) -> bool { + self.whitelist.get(&address.to_string()).unwrap_or_default() + } + + #[inline] + pub fn get_package_mode(&self) -> bool { + self.package_mode.get() + } + + pub fn update(&mut self, new_addresses: Maybe>) { + let new_addresses = new_addresses.unwrap_or_default(); + if !new_addresses.is_empty() { + match self.get_mode() { + WhitelistMode::Unlocked => { + self.whitelist.clear(); + for address in new_addresses.iter() { + self.whitelist.set(&address.to_string(), true); + } + } + WhitelistMode::Locked => self.env().revert(CEP78Error::InvalidWhitelistMode) + } + } + } +} diff --git a/modules/src/lib.rs b/modules/src/lib.rs index 57dfb2c7..de1036cd 100644 --- a/modules/src/lib.rs +++ b/modules/src/lib.rs @@ -1,12 +1,14 @@ #![doc = "Odra's library of plug and play modules"] #![cfg_attr(not(test), no_std)] #![cfg_attr(not(test), no_main)] +#![recursion_limit = "256"] extern crate alloc; pub mod access; pub mod cep18; pub mod cep18_token; +pub mod cep78; pub mod erc1155; pub mod erc1155_receiver; pub mod erc1155_token; @@ -15,4 +17,5 @@ pub mod erc721; pub mod erc721_receiver; pub mod erc721_token; pub mod security; +mod storage; pub mod wrapped_native; diff --git a/modules/src/storage.rs b/modules/src/storage.rs new file mode 100644 index 00000000..6172a120 --- /dev/null +++ b/modules/src/storage.rs @@ -0,0 +1,146 @@ +/// Creates an Odra module that stores a single value under a given named key. +/// The module has two methods: `set` and `get`. +/// If the value is not set and an error is passed as the fourth argument, `get` will revert with the provided error. +#[macro_export] +macro_rules! single_value_storage { + ($name:ident, $value_ty:ty, $key:expr, $err:expr) => { + #[odra::module] + pub struct $name; + + impl $name { + pub fn set(&self, value: $value_ty) { + self.env().set_named_value($key, value); + } + + pub fn get(&self) -> $value_ty { + use odra::UnwrapOrRevert; + self.env() + .get_named_value($key) + .unwrap_or_revert_with(&self.env(), $err) + } + } + }; + ($name:ident, $value_ty:ty, $key:expr) => { + #[odra::module] + pub struct $name; + + impl $name { + pub fn set(&self, value: $value_ty) { + self.env().set_named_value($key, value); + } + + pub fn get(&self) -> Option<$value_ty> { + self.env().get_named_value($key) + } + } + }; +} + +/// Creates an Odra module that stores a values in a given dictionary. +/// The module has two methods: `set` and `get`. +/// The `key` argument of `set` and `get` is used as a dictionary key. +#[macro_export] +macro_rules! key_value_storage { + ($name:ident, $dict:expr, $value_type:ty) => { + #[odra::module] + pub struct $name; + + impl $name { + pub fn set(&self, key: &str, value: $value_type) { + self.env() + .set_dictionary_value($dict, key.as_bytes(), value); + } + + pub fn get(&self, key: &str) -> Option<$value_type> { + self.env().get_dictionary_value($dict, key.as_bytes()) + } + } + }; +} + +/// Creates an Odra module that stores a values in a given dictionary. +/// The module has two methods: `set` and `get`. +/// The `key` argument of `set` and `get` is base64-encoded and then used as a dictionary key. +#[macro_export] +macro_rules! base64_encoded_key_value_storage { + ($name:ident, $dict:expr, $key:ty, $value_type:ty) => { + #[odra::module] + pub struct $name; + + impl $name { + pub fn set(&self, key: &$key, value: $value_type) { + let env = self.env(); + let encoded_key = Self::key(&env, key); + env.set_dictionary_value($dict, encoded_key.as_bytes(), value); + } + + pub fn get(&self, key: &$key) -> Option<$value_type> { + let env = self.env(); + let encoded_key = Self::key(&env, key); + env.get_dictionary_value($dict, encoded_key.as_bytes()) + } + + #[inline] + fn key(env: &odra::ContractEnv, key: &$key) -> String { + use base64::prelude::{Engine, BASE64_STANDARD}; + use odra::UnwrapOrRevert; + + let preimage = key.to_bytes().unwrap_or_revert(&env); + BASE64_STANDARD.encode(preimage) + } + } + }; +} + +/// Creates an Odra module that stores a values in a given dictionary. +/// The module has two methods: `set` and `get`. +/// The `key1` and `key2` arguments of `set` and `get` are converted to bytes, combined into a single bytes vector, +/// and finally hex-encoded and then used as a dictionary key. +#[macro_export] +macro_rules! compound_key_value_storage { + ($name:ident, $dict:expr, $k1_type:ty, $k2_type:ty, $value_type:ty) => { + #[odra::module] + pub struct $name; + + impl $name { + pub fn set(&self, key1: &$k1_type, key2: &$k2_type, value: $value_type) { + use odra::UnwrapOrRevert; + + let env = self.env(); + let parts = [ + key1.to_bytes().unwrap_or_revert(&env), + key2.to_bytes().unwrap_or_revert(&env) + ]; + let key = $crate::storage::compound_key(&env, &parts); + env.set_dictionary_value($dict, &key, value); + } + + pub fn get_or_default(&self, key1: &$k1_type, key2: &$k2_type) -> $value_type { + use odra::UnwrapOrRevert; + + let env = self.env(); + let parts = [ + key1.to_bytes().unwrap_or_revert(&env), + key2.to_bytes().unwrap_or_revert(&env) + ]; + let key = $crate::storage::compound_key(&env, &parts); + env.get_dictionary_value($dict, &key).unwrap_or_default() + } + } + }; + ($name:ident, $dict:expr, $k1_type:ty, $value_type:ty) => { + compound_key_value_storage!($name, $dict, $k1_type, $k1_type, $value_type); + }; +} + +pub(crate) fn compound_key(env: &odra::ContractEnv, parts: &[odra::prelude::Vec]) -> [u8; 64] { + let mut result = [0u8; 64]; + let mut preimage = odra::prelude::Vec::new(); + for part in parts { + preimage.extend_from_slice(part); + } + + let key_bytes = env.hash(&preimage); + odra::utils::hex_to_slice(&key_bytes, &mut result); + result +} diff --git a/odra-casper/livenet-env/src/livenet_contract_env.rs b/odra-casper/livenet-env/src/livenet_contract_env.rs index 79a0522a..a8e67157 100644 --- a/odra-casper/livenet-env/src/livenet_contract_env.rs +++ b/odra-casper/livenet-env/src/livenet_contract_env.rs @@ -39,7 +39,7 @@ impl ContractContext for LivenetContractEnv { panic!("Cannot set named value in LivenetEnv without a deploy") } - fn get_dictionary_value(&self, dictionary_name: &str, key: &str) -> Option { + fn get_dictionary_value(&self, dictionary_name: &str, key: &[u8]) -> Option { self.casper_client.borrow().get_dictionary_value( self.callstack.borrow().current().address(), dictionary_name, @@ -47,10 +47,14 @@ impl ContractContext for LivenetContractEnv { ) } - fn set_dictionary_value(&self, _dictionary_name: &str, _key: &str, _value: CLValue) { + fn set_dictionary_value(&self, _dictionary_name: &str, _key: &[u8], _value: CLValue) { panic!("Cannot set dictionary value in LivenetEnv without a deploy") } + fn remove_dictionary(&self, _dictionary_name: &str) { + panic!("Cannot remove dictionary value in LivenetEnv without a deploy") + } + fn caller(&self) -> Address { *self.callstack.borrow().first().address() } diff --git a/odra-casper/rpc-client/src/casper_client.rs b/odra-casper/rpc-client/src/casper_client.rs index bcc5de43..213cea5d 100644 --- a/odra-casper/rpc-client/src/casper_client.rs +++ b/odra-casper/rpc-client/src/casper_client.rs @@ -126,9 +126,10 @@ impl CasperClient { &self, address: &Address, dictionary_name: &str, - key: &str + key: &[u8] ) -> Option { - self.query_dict_bytes(address, dictionary_name.to_string(), key.to_string()) + let key = String::from_utf8(key.to_vec()).unwrap(); + self.query_dict_bytes(address, dictionary_name.to_string(), key) .ok() } diff --git a/odra-casper/wasm-env/src/host_functions.rs b/odra-casper/wasm-env/src/host_functions.rs index 7f9388c1..47ccfd2e 100644 --- a/odra-casper/wasm-env/src/host_functions.rs +++ b/odra-casper/wasm-env/src/host_functions.rs @@ -233,12 +233,14 @@ pub fn set_named_key(name: &str, value: CLValue) { /// Gets a value under a named key from the contract's storage. pub fn get_named_key(name: &str) -> Option { - let key = runtime::get_key(name).unwrap_or_revert(); - read(key) + match runtime::get_key(name) { + Some(key) => read(key), + None => None + } } /// Writes a value under a key in a dictionary to a contract's storage. -pub fn set_dictionary_value(dictionary_name: &str, key: &str, value: CLValue) { +pub fn set_dictionary_value(dictionary_name: &str, key: &[u8], value: CLValue) { let dictionary_uref = get_dictionary(dictionary_name); let (uref_ptr, uref_size, _bytes1) = to_ptr(dictionary_uref); let (dictionary_item_key_ptr, dictionary_item_key_size) = dictionary_item_key_to_ptr(key); @@ -264,8 +266,14 @@ pub fn set_dictionary_value(dictionary_name: &str, key: &str, value: CLValue) { result.unwrap_or_revert() } +/// Removes the [`Key`] stored under `dictionary_name` in the current context's named keys. +#[inline] +pub fn remove_dictionary(dictionary_name: &str) { + runtime::remove_key(dictionary_name); +} + /// Gets a value under a key in a dictionary from the contract's storage. -pub fn get_dictionary_value(dictionary_name: &str, key: &str) -> Option { +pub fn get_dictionary_value(dictionary_name: &str, key: &[u8]) -> Option { let dictionary_uref = get_dictionary(dictionary_name); let (uref_ptr, uref_size, _bytes1) = to_ptr(dictionary_uref); let (dictionary_item_key_ptr, dictionary_item_key_size) = dictionary_item_key_to_ptr(key); @@ -629,10 +637,9 @@ fn to_ptr(t: T) -> (*const u8, usize, Vec) { (ptr, size, bytes) } -fn dictionary_item_key_to_ptr(dictionary_item_key: &str) -> (*const u8, usize) { - let bytes = dictionary_item_key.as_bytes(); - let ptr = bytes.as_ptr(); - let size = bytes.len(); +fn dictionary_item_key_to_ptr(dictionary_item_key: &[u8]) -> (*const u8, usize) { + let ptr = dictionary_item_key.as_ptr(); + let size = dictionary_item_key.len(); (ptr, size) } diff --git a/odra-casper/wasm-env/src/wasm_contract_env.rs b/odra-casper/wasm-env/src/wasm_contract_env.rs index 774e4a33..4f676c09 100644 --- a/odra-casper/wasm-env/src/wasm_contract_env.rs +++ b/odra-casper/wasm-env/src/wasm_contract_env.rs @@ -30,14 +30,18 @@ impl ContractContext for WasmContractEnv { host_functions::set_named_key(name, value); } - fn get_dictionary_value(&self, dictionary_name: &str, key: &str) -> Option { + fn get_dictionary_value(&self, dictionary_name: &str, key: &[u8]) -> Option { host_functions::get_dictionary_value(dictionary_name, key) } - fn set_dictionary_value(&self, dictionary_name: &str, key: &str, value: CLValue) { + fn set_dictionary_value(&self, dictionary_name: &str, key: &[u8], value: CLValue) { host_functions::set_dictionary_value(dictionary_name, key, value); } + fn remove_dictionary(&self, dictionary_name: &str) { + host_functions::remove_dictionary(dictionary_name); + } + fn caller(&self) -> Address { host_functions::caller() } diff --git a/odra-schema/src/custom_type.rs b/odra-schema/src/custom_type.rs index 1b460fce..4784f261 100644 --- a/odra-schema/src/custom_type.rs +++ b/odra-schema/src/custom_type.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; use casper_contract_schema::CustomType; use casper_types::bytesrepr::{FromBytes, ToBytes}; +use casper_types::URef; use casper_types::{bytesrepr::Bytes, CLTyped, PublicKey, U128, U256, U512}; use num_traits::{Num, One, Zero}; use odra_core::{args::Maybe, Address}; @@ -50,7 +51,8 @@ impl_schema_custom_types!( String, (), PublicKey, - Bytes + Bytes, + URef ); impl SchemaCustomTypes for Option {} diff --git a/odra-schema/src/ty.rs b/odra-schema/src/ty.rs index a243b499..a24f9540 100644 --- a/odra-schema/src/ty.rs +++ b/odra-schema/src/ty.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; pub use casper_contract_schema; use casper_contract_schema::NamedCLType; -use casper_types::{bytesrepr::Bytes, Key, PublicKey, URef, U128, U256, U512}; +use casper_types::{bytesrepr::Bytes, ContractHash, Key, PublicKey, URef, U128, U256, U512}; use odra_core::{args::Maybe, Address}; /// Trait for types that can be represented as a NamedCLType. @@ -159,6 +159,12 @@ impl NamedCLTyped for PublicKey { } } +impl NamedCLTyped for ContractHash { + fn ty() -> NamedCLType { + NamedCLType::ByteArray(32) + } +} + impl NamedCLTyped for Address { fn ty() -> NamedCLType { NamedCLType::Key diff --git a/odra-vm/src/odra_vm_contract_env.rs b/odra-vm/src/odra_vm_contract_env.rs index 04300052..1ed21779 100644 --- a/odra-vm/src/odra_vm_contract_env.rs +++ b/odra-vm/src/odra_vm_contract_env.rs @@ -33,14 +33,18 @@ impl ContractContext for OdraVmContractEnv { self.vm.borrow().set_named_key(name, value) } - fn get_dictionary_value(&self, dictionary_name: &str, key: &str) -> Option { + fn get_dictionary_value(&self, dictionary_name: &str, key: &[u8]) -> Option { self.vm.borrow().get_dict_value(dictionary_name, key) } - fn set_dictionary_value(&self, dictionary_name: &str, key: &str, value: CLValue) { + fn set_dictionary_value(&self, dictionary_name: &str, key: &[u8], value: CLValue) { self.vm.borrow().set_dict_value(dictionary_name, key, value) } + fn remove_dictionary(&self, dictionary_name: &str) { + self.vm.borrow().remove_dictionary(dictionary_name); + } + fn caller(&self) -> Address { self.vm.borrow().caller() } diff --git a/odra-vm/src/vm/odra_vm.rs b/odra-vm/src/vm/odra_vm.rs index 785297b9..6db3fe1b 100644 --- a/odra-vm/src/vm/odra_vm.rs +++ b/odra-vm/src/vm/odra_vm.rs @@ -176,24 +176,32 @@ impl OdraVm { } /// Sets the value of the dictionary item. - pub fn set_dict_value(&self, dict: &str, key: &str, value: CLValue) { + pub fn set_dict_value(&self, dict: &str, key: &[u8], value: CLValue) { self.state.write().unwrap().set_dict_value( dict.as_bytes(), - key.as_bytes(), + key, Bytes::from(value.inner_bytes().as_slice()) ); } + /// Removes the dictionary from the global state. + pub fn remove_dictionary(&self, dictionary_name: &str) { + self.state + .write() + .unwrap() + .remove_dictionary(dictionary_name.as_bytes()); + } + /// Gets the value of the dictionary item. /// /// Returns `None` if the dictionary or the key does not exist. /// If the dictionary or the key does not exist, the virtual machine is in error state. - pub fn get_dict_value(&self, dict: &str, key: &str) -> Option { + pub fn get_dict_value(&self, dict: &str, key: &[u8]) -> Option { let result = { self.state .read() .unwrap() - .get_dict_value(dict.as_bytes(), key.as_bytes()) + .get_dict_value(dict.as_bytes(), key) }; match result { Ok(result) => result, @@ -564,7 +572,7 @@ mod tests { // when set a value let dict = "dict"; - let key = "key"; + let key = b"key"; let value = CLValue::from_t("value").unwrap(); instance.set_dict_value(dict, key, value.clone()); @@ -576,7 +584,7 @@ mod tests { // then the value under the key in unknown dict does not exist assert_eq!(instance.get_dict_value("other_dict", key), None); // then the value under unknown key does not exist - assert_eq!(instance.get_dict_value(dict, "other_key"), None); + assert_eq!(instance.get_dict_value(dict, b"other_key"), None); } #[test] diff --git a/odra-vm/src/vm/odra_vm_state.rs b/odra-vm/src/vm/odra_vm_state.rs index 617065f5..0caaf00b 100644 --- a/odra-vm/src/vm/odra_vm_state.rs +++ b/odra-vm/src/vm/odra_vm_state.rs @@ -62,6 +62,11 @@ impl OdraVmState { } } + pub fn remove_dictionary(&mut self, dict: &[u8]) { + let ctx = self.callstack.current().address(); + self.storage.remove_dict(ctx, dict); + } + pub fn get_dict_value(&self, dict: &[u8], key: &[u8]) -> Result, Error> { let ctx = &self.callstack.current().address(); self.storage.get_dict_value(ctx, dict, key) diff --git a/odra-vm/src/vm/storage.rs b/odra-vm/src/vm/storage.rs index 6e7597f3..57a9cf68 100644 --- a/odra-vm/src/vm/storage.rs +++ b/odra-vm/src/vm/storage.rs @@ -16,8 +16,10 @@ use super::balance::AccountBalance; #[derive(Default, Clone)] pub struct Storage { state: BTreeMap, + named_state: BTreeMap>, pub balances: BTreeMap, state_snapshot: Option>, + named_state_snapshot: Option>>, balances_snapshot: Option> } @@ -25,8 +27,10 @@ impl Storage { pub fn new(balances: BTreeMap) -> Self { Self { state: Default::default(), + named_state: Default::default(), balances, state_snapshot: Default::default(), + named_state_snapshot: Default::default(), balances_snapshot: Default::default() } } @@ -69,32 +73,44 @@ impl Storage { key: &[u8], value: Bytes ) -> Result<(), Error> { - let dict_key = [collection, key].concat(); - let hash = Storage::hashed_key(address, dict_key); - self.state.insert(hash, value); + let dict = Self::hashed_key(address, collection); + let hash = Storage::hashed_key(address, key); + let dict_values = self.named_state.entry(dict).or_default(); + dict_values.insert(hash, value); Ok(()) } + pub fn remove_dict(&mut self, address: &Address, collection: &[u8]) { + let dict = Self::hashed_key(address, collection); + self.named_state.remove(&dict); + } + pub fn get_dict_value( &self, address: &Address, collection: &[u8], key: &[u8] ) -> Result, Error> { - let dict_key = [collection, key].concat(); - let hash = Storage::hashed_key(address, dict_key); - let result = self.state.get(&hash).cloned(); - - Ok(result) + let dict = Self::hashed_key(address, collection); + let hash = Storage::hashed_key(address, key); + let dict_values = self.named_state.get(&dict); + if let Some(dict) = dict_values { + let result = dict.get(&hash).cloned(); + Ok(result) + } else { + Ok(None) + } } pub fn take_snapshot(&mut self) { self.state_snapshot = Some(self.state.clone()); + self.named_state_snapshot = Some(self.named_state.clone()); self.balances_snapshot = Some(self.balances.clone()); } pub fn drop_snapshot(&mut self) { self.state_snapshot = None; + self.named_state_snapshot = None; self.balances_snapshot = None; } @@ -103,6 +119,10 @@ impl Storage { self.state = snapshot; self.state_snapshot = None; }; + if let Some(snapshot) = self.named_state_snapshot.clone() { + self.named_state = snapshot; + self.named_state_snapshot = None; + }; if let Some(snapshot) = self.balances_snapshot.clone() { self.balances = snapshot; self.balances_snapshot = None; @@ -247,6 +267,46 @@ mod test { assert_eq!(result, None); } + #[test] + fn remove_dict_erases_all_dict_records() { + // given storage with some stored value + let mut storage = Storage::default(); + let address = utils::account_address_from_str("add"); + let key1 = b"key"; + let key2 = b"key2"; + let value1 = 88u8; + let value2 = 89u8; + let collection = b"dict"; + storage + .insert_dict_value(&address, collection, key1, serialize(&value1)) + .unwrap(); + storage + .insert_dict_value(&address, collection, key2, serialize(&value2)) + .unwrap(); + + assert_eq!( + storage.get_dict_value(&address, collection, key1).unwrap(), + Some(serialize(&value1)) + ); + assert_eq!( + storage.get_dict_value(&address, collection, key2).unwrap(), + Some(serialize(&value2)) + ); + + // when remove a dictionary + storage.remove_dict(&address, collection); + + // then all values from the dictionary are removed + assert_eq!( + storage.get_dict_value(&address, collection, key1).unwrap(), + None + ); + assert_eq!( + storage.get_dict_value(&address, collection, key2).unwrap(), + None + ); + } + #[test] fn restore_snapshot() { // given storage with some state and a snapshot of the previous state @@ -255,11 +315,17 @@ mod test { storage .set_value(&address, &key, serialize(&initial_value)) .unwrap(); + storage + .insert_dict_value(&address, b"dict", &key, serialize(&initial_value)) + .unwrap(); storage.take_snapshot(); let next_value = String::from("next_value"); storage .set_value(&address, &key, serialize(&next_value)) .unwrap(); + storage + .insert_dict_value(&address, b"dict", &key, serialize(&next_value)) + .unwrap(); // when restore the snapshot storage.restore_snapshot(); @@ -269,6 +335,10 @@ mod test { storage.get_value(&address, &key).unwrap(), Some(serialize(&initial_value)) ); + assert_eq!( + storage.get_dict_value(&address, b"dict", &key).unwrap(), + Some(serialize(&initial_value)) + ); // the snapshot is removed assert_eq!(storage.state_snapshot, None); } diff --git a/odra/src/lib.rs b/odra/src/lib.rs index c92bc3c0..e4a97af7 100644 --- a/odra/src/lib.rs +++ b/odra/src/lib.rs @@ -38,7 +38,7 @@ #![no_std] pub use odra_core::{ - args, arithmetic, contract_def, entry_point_callback, host, module, prelude, uints + args, arithmetic, contract_def, entry_point_callback, host, module, prelude, uints, utils }; pub use odra_core::{casper_event_standard, casper_event_standard::Event, casper_types}; pub use odra_core::{