--- a/.docker/Dockerfile-chain-dev-unit +++ b/.docker/Dockerfile-chain-dev-unit @@ -17,4 +17,4 @@ WORKDIR /dev_chain -CMD cargo test --features=limit-testing --workspace +CMD cargo test --features=limit-testing,tests --workspace --- a/pallets/common/Cargo.toml +++ b/pallets/common/Cargo.toml @@ -36,4 +36,5 @@ "up-pov-estimate-rpc/std", ] stubgen = ["evm-coder/stubgen"] +tests = [] try-runtime = ["frame-support/try-runtime"] --- a/pallets/common/src/eth.rs +++ b/pallets/common/src/eth.rs @@ -131,6 +131,18 @@ value: evm_coder::types::Bytes, } +impl Property { + /// Property key. + pub fn key(&self) -> &str { + self.key.as_str() + } + + /// Property value. + pub fn value(&self) -> &[u8] { + self.value.0.as_slice() + } +} + impl TryFrom for Property { type Error = pallet_evm_coder_substrate::execution::Error; @@ -227,11 +239,9 @@ Some(value) => match value { 0 => Ok(Some(false)), 1 => Ok(Some(true)), - _ => { - return Err(Self::Error::Revert(format!( - "can't convert value to boolean \"{value}\"" - ))) - } + _ => Err(Self::Error::Revert(format!( + "can't convert value to boolean \"{value}\"" + ))), }, None => Ok(None), }; --- a/pallets/common/src/lib.rs +++ b/pallets/common/src/lib.rs @@ -216,7 +216,6 @@ /// /// # Arguments /// - /// * `sender`: Caller's account. /// * `sponsor`: ID of the account of the sponsor-to-be. pub fn force_set_sponsor(&mut self, sponsor: T::AccountId) -> DispatchResult { self.check_is_internal()?; @@ -867,6 +866,74 @@ >; } +/// Represents the change mode for the token property. +pub enum SetPropertyMode { + /// The token already exists. + ExistingToken, + + /// New token. + NewToken { + /// The creator of the token is the recipient. + mint_target_is_sender: bool, + }, +} + +/// Value representation with delayed initialization time. +pub struct LazyValue T> { + value: Option, + f: Option, +} + +impl T> LazyValue { + /// Create a new LazyValue. + pub fn new(f: F) -> Self { + Self { + value: None, + f: Some(f), + } + } + + /// Get the value. If it call furst time the value will be initialized. + pub fn value(&mut self) -> &T { + if self.value.is_none() { + self.value = Some(self.f.take().unwrap()()) + } + + self.value.as_ref().unwrap() + } + + /// Is value initialized. + pub fn has_value(&self) -> bool { + self.value.is_some() + } +} + +fn check_token_permissions( + collection_admin_permitted: bool, + token_owner_permitted: bool, + is_collection_admin: &mut LazyValue, + is_token_owner: &mut LazyValue, FTO>, + is_token_exist: &mut LazyValue, +) -> DispatchResult +where + T: Config, + FCA: FnOnce() -> bool, + FTO: FnOnce() -> Result, + FTE: FnOnce() -> bool, +{ + if !(collection_admin_permitted && *is_collection_admin.value() + || token_owner_permitted && (*is_token_owner.value())?) + { + fail!(>::NoPermission); + } + + let token_certainly_exist = is_token_owner.has_value() && (*is_token_owner.value())?; + if !token_certainly_exist && !is_token_exist.value() { + fail!(>::TokenNotFound); + } + Ok(()) +} + impl Pallet { /// Enshure that receiver address is correct. /// @@ -1218,10 +1285,6 @@ /// * sets a property under the with the value provided `(, Some())` /// * removes a property under the if the value is `None` `(, None)`. /// - /// - `nesting_budget`: Limit for searching parents in-depth to check ownership. - /// - `is_token_create`: Indicates that method is called during token initialization. - /// Allows to bypass ownership check. - /// /// All affected properties should have `mutable` permission /// to be **deleted** or to be **set more than once**, /// and the sender should have permission to edit those properties. @@ -1229,35 +1292,36 @@ /// This function fires an event for each property change. /// In case of an error, all the changes (including the events) will be reverted /// since the function is transactional. - pub fn modify_token_properties( + #[allow(clippy::too_many_arguments)] + pub fn modify_token_properties( collection: &CollectionHandle, sender: &T::CrossAccountId, token_id: TokenId, + is_token_exist: &mut LazyValue, properties_updates: impl Iterator)>, - is_token_create: bool, mut stored_properties: TokenProperties, - is_token_owner: impl Fn() -> Result, + is_token_owner: &mut LazyValue, FTO>, set_token_properties: impl FnOnce(TokenProperties), log: evm_coder::ethereum::Log, - ) -> DispatchResult { - let is_collection_admin = collection.is_owner_or_admin(sender); + ) -> DispatchResult + where + FTO: FnOnce() -> Result, + FTE: FnOnce() -> bool, + { + let mut is_collection_admin = LazyValue::new(|| collection.is_owner_or_admin(sender)); let permissions = Self::property_permissions(collection.id); - let mut token_owner_result = None; - let mut is_token_owner = || -> Result { - *token_owner_result.get_or_insert_with(&is_token_owner) - }; - + let mut changed = false; for (key, value) in properties_updates { let permission = permissions .get(&key) .cloned() .unwrap_or_else(PropertyPermission::none); - let is_property_exists = stored_properties.get(&key).is_some(); + let property_exists = stored_properties.get(&key).is_some(); match permission { - PropertyPermission { mutable: false, .. } if is_property_exists => { + PropertyPermission { mutable: false, .. } if property_exists => { return Err(>::NoPermission.into()); } @@ -1265,17 +1329,13 @@ collection_admin, token_owner, .. - } => { - //TODO: investigate threats during public minting. - let is_token_create = - is_token_create && (collection_admin || token_owner) && value.is_some(); - if !(is_token_create - || (collection_admin && is_collection_admin) - || (token_owner && is_token_owner()?)) - { - fail!(>::NoPermission); - } - } + } => check_token_permissions::( + collection_admin, + token_owner, + &mut is_collection_admin, + is_token_owner, + is_token_exist, + )?, } match value { @@ -1293,9 +1353,13 @@ } } - >::deposit_log(log.clone()); + changed = true; } + if changed { + >::deposit_log(log); + } + set_token_properties(stored_properties); Ok(()) @@ -2322,3 +2386,86 @@ } } } + +#[cfg(feature = "tests")] +pub mod tests { + use crate::{DispatchResult, DispatchError, LazyValue, Config}; + + const fn to_bool(u: u8) -> bool { + u != 0 + } + + #[derive(Debug)] + pub struct TestCase { + pub collection_admin: bool, + pub is_collection_admin: bool, + pub token_owner: bool, + pub is_token_owner: bool, + pub no_permission: bool, + } + + impl TestCase { + const fn new( + collection_admin: u8, + is_collection_admin: u8, + token_owner: u8, + is_token_owner: u8, + no_permission: u8, + ) -> Self { + Self { + collection_admin: to_bool(collection_admin), + is_collection_admin: to_bool(is_collection_admin), + token_owner: to_bool(token_owner), + is_token_owner: to_bool(is_token_owner), + no_permission: to_bool(no_permission), + } + } + } + + #[rustfmt::skip] + pub const table: [TestCase; 16] = [ + // ┌╴collection_admin + // │ ┌╴is_collection_admin + // │ │ ┌╴token_owner + // │ │ │ ┌╴is_token_ownership + // │ │ │ │ ┌╴no_permission + /* 0*/ TestCase::new(0, 0, 0, 0, 1), + /* 1*/ TestCase::new(0, 0, 0, 1, 1), + /* 2*/ TestCase::new(0, 0, 1, 0, 1), + /* 3*/ TestCase::new(0, 0, 1, 1, 0), + /* 4*/ TestCase::new(0, 1, 0, 0, 1), + /* 5*/ TestCase::new(0, 1, 0, 1, 1), + /* 6*/ TestCase::new(0, 1, 1, 0, 1), + /* 7*/ TestCase::new(0, 1, 1, 1, 0), + /* 8*/ TestCase::new(1, 0, 0, 0, 1), + /* 9*/ TestCase::new(1, 0, 0, 1, 1), + /* 10*/ TestCase::new(1, 0, 1, 0, 1), + /* 11*/ TestCase::new(1, 0, 1, 1, 0), + /* 12*/ TestCase::new(1, 1, 0, 0, 0), + /* 13*/ TestCase::new(1, 1, 0, 1, 0), + /* 14*/ TestCase::new(1, 1, 1, 0, 0), + /* 15*/ TestCase::new(1, 1, 1, 1, 0), + ]; + + pub fn check_token_permissions( + collection_admin_permitted: bool, + token_owner_permitted: bool, + is_collection_admin: &mut LazyValue, + check_token_ownership: &mut LazyValue, FTO>, + check_token_existence: &mut LazyValue, + ) -> DispatchResult + where + T: Config, + FCA: FnOnce() -> bool, + FTO: FnOnce() -> Result, + FTE: FnOnce() -> bool, + { + crate::check_token_permissions::( + collection_admin_permitted, + token_owner_permitted, + is_collection_admin, + check_token_ownership, + check_token_existence, + ) + } +} --- a/pallets/nonfungible/src/common.rs +++ b/pallets/nonfungible/src/common.rs @@ -245,7 +245,7 @@ &sender, token_id, properties.into_iter(), - false, + pallet_common::SetPropertyMode::ExistingToken, nesting_budget, ), weight, --- a/pallets/nonfungible/src/erc.rs +++ b/pallets/nonfungible/src/erc.rs @@ -194,7 +194,7 @@ &caller, TokenId(token_id), properties.into_iter(), - false, + pallet_common::SetPropertyMode::ExistingToken, &nesting_budget, ) .map_err(dispatch_to_evm::) @@ -939,9 +939,8 @@ /// @notice Returns next free NFT ID. fn next_token_id(&self) -> Result { self.consume_store_reads(1)?; - Ok(>::get(self.id) - .checked_add(1) - .ok_or("item id overflow")? + Ok(>::next_token_id(self) + .map_err(dispatch_to_evm::)? .into()) } --- a/pallets/nonfungible/src/lib.rs +++ b/pallets/nonfungible/src/lib.rs @@ -109,7 +109,7 @@ use pallet_common::{ Error as CommonError, Pallet as PalletCommon, Event as CommonEvent, CollectionHandle, eth::collection_id_to_address, SelfWeightOf as PalletCommonWeightOf, - weights::WeightInfo as CommonWeightInfo, helpers::add_weight_to_post_info, + weights::WeightInfo as CommonWeightInfo, helpers::add_weight_to_post_info, SetPropertyMode, }; use pallet_structure::{Pallet as PalletStructure, Error as StructureError}; use pallet_evm_coder_substrate::{SubstrateRecorder, WithRecorder}; @@ -585,8 +585,6 @@ /// A batch operation to add, edit or remove properties for a token. /// /// - `nesting_budget`: Limit for searching parents in-depth to check ownership. - /// - `is_token_create`: Indicates that method is called during token initialization. - /// Allows to bypass ownership check. /// /// All affected properties should have `mutable` permission /// to be **deleted** or to be **set more than once**, @@ -601,10 +599,17 @@ sender: &T::CrossAccountId, token_id: TokenId, properties_updates: impl Iterator)>, - is_token_create: bool, + mode: SetPropertyMode, nesting_budget: &dyn Budget, ) -> DispatchResult { - let is_token_owner = || { + let mut is_token_owner = pallet_common::LazyValue::new(|| { + if let SetPropertyMode::NewToken { + mint_target_is_sender, + } = mode + { + return Ok(mint_target_is_sender); + } + let is_owned = >::check_indirectly_owned( sender.clone(), collection.id, @@ -614,18 +619,21 @@ )?; Ok(is_owned) - }; + }); + let mut is_token_exist = + pallet_common::LazyValue::new(|| Self::token_exists(collection, token_id)); + let stored_properties = >::get((collection.id, token_id)); >::modify_token_properties( collection, sender, token_id, + &mut is_token_exist, properties_updates, - is_token_create, stored_properties, - is_token_owner, + &mut is_token_owner, |properties| >::set((collection.id, token_id), properties), erc::ERC721TokenEvent::TokenChanged { token_id: token_id.into(), @@ -634,6 +642,19 @@ ) } + pub fn next_token_id(collection: &NonfungibleHandle) -> Result { + let next_token_id = >::get(collection.id) + .checked_add(1) + .ok_or(>::CollectionTokenLimitExceeded)?; + + ensure!( + collection.limits.token_limit() >= next_token_id, + >::CollectionTokenLimitExceeded + ); + + Ok(TokenId(next_token_id)) + } + /// Batch operation to add or edit properties for the token /// /// Same as [`modify_token_properties`] but doesn't allow to remove properties @@ -644,7 +665,7 @@ sender: &T::CrossAccountId, token_id: TokenId, properties: impl Iterator, - is_token_create: bool, + mode: SetPropertyMode, nesting_budget: &dyn Budget, ) -> DispatchResult { Self::modify_token_properties( @@ -652,7 +673,7 @@ sender, token_id, properties.map(|p| (p.key, Some(p.value))), - is_token_create, + mode, nesting_budget, ) } @@ -669,14 +690,12 @@ property: Property, nesting_budget: &dyn Budget, ) -> DispatchResult { - let is_token_create = false; - Self::set_token_properties( collection, sender, token_id, [property].into_iter(), - is_token_create, + SetPropertyMode::ExistingToken, nesting_budget, ) } @@ -693,14 +712,12 @@ property_keys: impl Iterator, nesting_budget: &dyn Budget, ) -> DispatchResult { - let is_token_create = false; - Self::modify_token_properties( collection, sender, token_id, property_keys.into_iter().map(|key| (key, None)), - is_token_create, + SetPropertyMode::ExistingToken, nesting_budget, ) } @@ -985,7 +1002,9 @@ sender, TokenId(token), data.properties.clone().into_iter(), - true, + SetPropertyMode::NewToken { + mint_target_is_sender: sender.conv_eq(&data.owner), + }, nesting_budget, ) { return TransactionOutcome::Rollback(Err(e)); --- a/pallets/refungible/src/common.rs +++ b/pallets/refungible/src/common.rs @@ -399,7 +399,7 @@ &sender, token_id, properties.into_iter(), - false, + pallet_common::SetPropertyMode::ExistingToken, nesting_budget, ), weight, --- a/pallets/refungible/src/erc.rs +++ b/pallets/refungible/src/erc.rs @@ -196,7 +196,7 @@ &caller, TokenId(token_id), properties.into_iter(), - false, + pallet_common::SetPropertyMode::ExistingToken, &nesting_budget, ) .map_err(dispatch_to_evm::) @@ -973,9 +973,8 @@ /// @notice Returns next free RFT ID. fn next_token_id(&self) -> Result { self.consume_store_reads(1)?; - Ok(>::get(self.id) - .checked_add(1) - .ok_or("item id overflow")? + Ok(>::next_token_id(self) + .map_err(dispatch_to_evm::)? .into()) } --- a/pallets/refungible/src/lib.rs +++ b/pallets/refungible/src/lib.rs @@ -97,7 +97,7 @@ use pallet_evm_coder_substrate::WithRecorder; use pallet_common::{ CommonCollectionOperations, Error as CommonError, eth::collection_id_to_address, - Event as CommonEvent, Pallet as PalletCommon, + Event as CommonEvent, Pallet as PalletCommon, SetPropertyMode, }; use pallet_structure::Pallet as PalletStructure; use sp_core::{Get, H160}; @@ -521,8 +521,6 @@ /// * removes a property under the if the value is `None` `(, None)`. /// /// - `nesting_budget`: Limit for searching parents in-depth to check ownership. - /// - `is_token_create`: Indicates that method is called during token initialization. - /// Allows to bypass ownership check. /// /// All affected properties should have `mutable` permission /// to be **deleted** or to be **set more than once**, @@ -537,27 +535,38 @@ sender: &T::CrossAccountId, token_id: TokenId, properties_updates: impl Iterator)>, - is_token_create: bool, + mode: SetPropertyMode, nesting_budget: &dyn Budget, ) -> DispatchResult { - let is_token_owner = || -> Result { - let balance = collection.balance(sender.clone(), token_id); - let total_pieces: u128 = - Self::total_pieces(collection.id, token_id).unwrap_or(u128::MAX); - if balance != total_pieces { - return Ok(false); - } + let mut is_token_owner = + pallet_common::LazyValue::new(|| -> Result { + if let SetPropertyMode::NewToken { + mint_target_is_sender, + } = mode + { + return Ok(mint_target_is_sender); + } - let is_bundle_owner = >::check_indirectly_owned( - sender.clone(), - collection.id, - token_id, - None, - nesting_budget, - )?; + let balance = collection.balance(sender.clone(), token_id); + let total_pieces: u128 = + Self::total_pieces(collection.id, token_id).unwrap_or(u128::MAX); + if balance != total_pieces { + return Ok(false); + } + + let is_bundle_owner = >::check_indirectly_owned( + sender.clone(), + collection.id, + token_id, + None, + nesting_budget, + )?; + + Ok(is_bundle_owner) + }); - Ok(is_bundle_owner) - }; + let mut is_token_exist = + pallet_common::LazyValue::new(|| Self::token_exists(collection, token_id)); let stored_properties = >::get((collection.id, token_id)); @@ -565,10 +574,10 @@ collection, sender, token_id, + &mut is_token_exist, properties_updates, - is_token_create, stored_properties, - is_token_owner, + &mut is_token_owner, |properties| >::set((collection.id, token_id), properties), erc::ERC721TokenEvent::TokenChanged { token_id: token_id.into(), @@ -577,12 +586,25 @@ ) } + pub fn next_token_id(collection: &RefungibleHandle) -> Result { + let next_token_id = >::get(collection.id) + .checked_add(1) + .ok_or(>::CollectionTokenLimitExceeded)?; + + ensure!( + collection.limits.token_limit() >= next_token_id, + >::CollectionTokenLimitExceeded + ); + + Ok(TokenId(next_token_id)) + } + pub fn set_token_properties( collection: &RefungibleHandle, sender: &T::CrossAccountId, token_id: TokenId, properties: impl Iterator, - is_token_create: bool, + mode: SetPropertyMode, nesting_budget: &dyn Budget, ) -> DispatchResult { Self::modify_token_properties( @@ -590,7 +612,7 @@ sender, token_id, properties.map(|p| (p.key, Some(p.value))), - is_token_create, + mode, nesting_budget, ) } @@ -602,14 +624,12 @@ property: Property, nesting_budget: &dyn Budget, ) -> DispatchResult { - let is_token_create = false; - Self::set_token_properties( collection, sender, token_id, [property].into_iter(), - is_token_create, + SetPropertyMode::ExistingToken, nesting_budget, ) } @@ -621,14 +641,12 @@ property_keys: impl Iterator, nesting_budget: &dyn Budget, ) -> DispatchResult { - let is_token_create = false; - Self::modify_token_properties( collection, sender, token_id, property_keys.into_iter().map(|key| (key, None)), - is_token_create, + SetPropertyMode::ExistingToken, nesting_budget, ) } @@ -914,10 +932,14 @@ let token_id = first_token_id + i as u32 + 1; >::insert((collection.id, token_id), totals[i]); + let mut mint_target_is_sender = true; for (user, amount) in data.users.iter() { if *amount == 0 { continue; } + + mint_target_is_sender = mint_target_is_sender && sender.conv_eq(user); + >::insert((collection.id, token_id, &user), amount); >::insert((collection.id, &user, TokenId(token_id)), true); >::nest_if_sent_to_token_unchecked( @@ -932,7 +954,9 @@ sender, TokenId(token_id), data.properties.clone().into_iter(), - true, + SetPropertyMode::NewToken { + mint_target_is_sender, + }, nesting_budget, ) { return TransactionOutcome::Rollback(Err(e)); --- a/runtime/common/ethereum/sponsoring.rs +++ b/runtime/common/ethereum/sponsoring.rs @@ -22,7 +22,7 @@ use pallet_evm::account::CrossAccountId; use pallet_evm_transaction_payment::CallContext; use pallet_nonfungible::{ - Config as NonfungibleConfig, + Config as NonfungibleConfig, Pallet as NonfungiblePallet, NonfungibleHandle, erc::{ UniqueNFTCall, ERC721UniqueExtensionsCall, ERC721UniqueMintableCall, ERC721Call, TokenPropertiesCall, @@ -56,6 +56,8 @@ pub struct UniqueEthSponsorshipHandler(PhantomData<*const T>); impl SponsorshipHandler for UniqueEthSponsorshipHandler +where + T::AccountId: From<[u8; 32]>, { fn get_sponsor( who: &T::CrossAccountId, @@ -67,29 +69,71 @@ let (method_id, mut reader) = AbiReader::new_call(&call_context.input).ok()?; Some(T::CrossAccountId::from_sub(match &collection.mode { CollectionMode::NFT => { + let collection = NonfungibleHandle::cast(collection); let call = >::parse(method_id, &mut reader).ok()??; match call { - UniqueNFTCall::TokenProperties(TokenPropertiesCall::SetProperty { - token_id, - key, - value, - .. - }) => { - let token_id: TokenId = token_id.try_into().ok()?; - withdraw_set_token_property::( - &collection, - who, - &token_id, - key.len() + value.len(), - ) - .map(|()| sponsor) - } - UniqueNFTCall::ERC721UniqueExtensions( - ERC721UniqueExtensionsCall::Transfer { token_id, .. }, - ) => { - let token_id: TokenId = token_id.try_into().ok()?; - withdraw_transfer::(&collection, who, &token_id).map(|()| sponsor) - } + UniqueNFTCall::TokenProperties(call) => match call { + TokenPropertiesCall::SetProperty { + token_id, + key, + value, + .. + } => { + let token_id: TokenId = token_id.try_into().ok()?; + withdraw_set_existing_token_property::( + &collection, + who, + &token_id, + key.len() + value.len(), + ) + .map(|()| sponsor) + } + TokenPropertiesCall::SetProperties { + token_id, + properties, + .. + } => { + let token_id: TokenId = token_id.try_into().ok()?; + let data_size = properties + .into_iter() + .map(|p| p.key().len() + p.value().len()) + .sum(); + + withdraw_set_existing_token_property::( + &collection, + who, + &token_id, + data_size, + ) + .map(|()| sponsor) + } + _ => None, + }, + UniqueNFTCall::ERC721UniqueExtensions(call) => match call { + ERC721UniqueExtensionsCall::Transfer { token_id, .. } => { + let token_id: TokenId = token_id.try_into().ok()?; + withdraw_transfer::(&collection, who, &token_id) + .map(|()| sponsor) + } + ERC721UniqueExtensionsCall::MintCross { properties, .. } => { + withdraw_create_item::( + &collection, + who, + &CreateItemData::NFT(CreateNftData::default()), + )?; + + let token_id = + >::next_token_id(&collection).ok()?; + let data_size: usize = properties + .into_iter() + .map(|p| p.key().len() + p.value().len()) + .sum(); + + withdraw_set_token_property::(&collection, &token_id, data_size) + .map(|()| sponsor) + } + _ => None, + }, UniqueNFTCall::ERC721UniqueMintable( ERC721UniqueMintableCall::Mint { .. } | ERC721UniqueMintableCall::MintCheckId { .. } --- a/runtime/common/ethereum/sponsoring/refungible.rs +++ b/runtime/common/ethereum/sponsoring/refungible.rs @@ -94,7 +94,12 @@ .. } => { let token_id = TokenId::try_from(token_id).ok()?; - withdraw_set_token_property::(&collection, who, &token_id, key.len() + value.len()) + withdraw_set_existing_token_property::( + &collection, + who, + &token_id, + key.len() + value.len(), + ) } } } --- a/runtime/common/sponsoring.rs +++ b/runtime/common/sponsoring.rs @@ -39,7 +39,7 @@ impl Config for T where T: UniqueConfig + FungibleConfig + NonfungibleConfig + RefungibleConfig {} // TODO: permission check? -pub fn withdraw_set_token_property( +pub fn withdraw_set_existing_token_property( collection: &CollectionHandle, who: &T::CrossAccountId, item_id: &TokenId, @@ -64,6 +64,17 @@ } } + withdraw_set_token_property(collection, item_id, data_size) +} + +pub fn withdraw_set_token_property( + collection: &CollectionHandle, + item_id: &TokenId, + data_size: usize, +) -> Option<()> { + if data_size == 0 { + return Some(()); + } if data_size > collection.limits.sponsored_data_size() as usize { return None; } @@ -173,7 +184,6 @@ return None; } } - CreateItemBasket::::insert((collection.id, who.as_sub()), block_number); Some(()) @@ -237,7 +247,7 @@ .. } => { let (sponsor, collection) = load::(*collection_id)?; - withdraw_set_token_property( + withdraw_set_existing_token_property( &collection, &T::CrossAccountId::from_sub(who.clone()), token_id, --- a/runtime/tests/Cargo.toml +++ b/runtime/tests/Cargo.toml @@ -5,6 +5,7 @@ [features] default = ['refungible'] +tests = ['pallet-common/tests'] refungible = [] --- a/runtime/tests/src/tests.rs +++ b/runtime/tests/src/tests.rs @@ -1737,6 +1737,11 @@ let collection_id = create_test_collection(&CollectionMode::NFT, CollectionId(1)); let origin1 = RuntimeOrigin::signed(1); + assert_ok!(Unique::add_collection_admin( + origin1.clone(), + collection_id, + account(1) + )); let data = default_nft_data(); create_test_item(collection_id, &data.into()); @@ -2610,3 +2615,67 @@ )); }); } + +mod check_token_permissions { + use super::*; + use frame_support::once_cell::sync::Lazy; + use pallet_common::LazyValue; + use sp_runtime::DispatchError; + + fn test bool>( + i: usize, + test_case: &pallet_common::tests::TestCase, + check_token_existence: &mut LazyValue, + ) { + let collection_admin = test_case.collection_admin; + let mut is_collection_admin = LazyValue::new(|| test_case.is_collection_admin); + let token_owner = test_case.token_owner; + let mut is_token_owner = LazyValue::new(|| Ok(test_case.is_token_owner)); + let is_no_permission = test_case.no_permission; + + let result = pallet_common::tests::check_token_permissions::( + collection_admin, + token_owner, + &mut is_collection_admin, + &mut is_token_owner, + check_token_existence, + ); + + if is_no_permission { + assert!( + result.is_err(), + "{i}: {test_case:?}, token_exist: {}", + check_token_existence.value() + ); + assert_err!(result, pallet_common::Error::::NoPermission,); + } else if check_token_existence.has_value() && !check_token_existence.value() { + assert!( + result.is_err(), + "{i}: {test_case:?}, token_exist: {}", + check_token_existence.value() + ); + assert_err!(result, pallet_common::Error::::TokenNotFound,); + } + } + + #[test] + fn no_permission_only() { + new_test_ext().execute_with(|| { + let mut check_token_existence = LazyValue::new(|| true); + for (i, row) in pallet_common::tests::table.iter().enumerate() { + test(i, row, &mut check_token_existence); + } + }); + } + + #[test] + fn no_permission_and_token_not_found() { + new_test_ext().execute_with(|| { + for (i, row) in pallet_common::tests::table.iter().enumerate() { + // This is inside the loop to keep track of whether the lambda was called + let mut check_token_existence = LazyValue::new(|| false); + test(i, row, &mut check_token_existence); + } + }); + } +} --- a/tests/src/createMultipleItemsEx.test.ts +++ b/tests/src/createMultipleItemsEx.test.ts @@ -195,7 +195,7 @@ description: 'descr', tokenPrefix: 'COL', tokenPropertyPermissions: [ - {key: 'k', permission: {tokenOwner: true, mutable: false, collectionAdmin: false}}, + {key: 'k', permission: {tokenOwner: false, mutable: false, collectionAdmin: true}}, ], }); --- a/tests/src/eth/collectionSponsoring.test.ts +++ b/tests/src/eth/collectionSponsoring.test.ts @@ -17,6 +17,7 @@ import {IKeyringPair} from '@polkadot/types/types'; import {Pallets, requirePalletsOrSkip, usingPlaygrounds} from '../util/index'; import {itEth, expect} from './util'; +import {CollectionLimitField, TokenPermissionField} from './util/playgrounds/types'; describe('evm nft collection sponsoring', () => { let donor: IKeyringPair; @@ -138,8 +139,7 @@ expect(sponsorship.Confirmed).to.be.eq(helper.address.ethToSubstrate(sponsorEth, true)); // Create user with no balance: - const user = helper.eth.createAccount(); - const userCross = helper.ethCrossAccount.fromAddress(user); + const user = helper.ethCrossAccount.createAccount(); const nextTokenId = await collectionEvm.methods.nextTokenId().call(); expect(nextTokenId).to.be.equal('1'); @@ -149,20 +149,29 @@ expect(oldPermissions.access).to.be.equal('Normal'); await collectionEvm.methods.setCollectionAccess(1 /*'AllowList'*/).send({from: owner}); - await collectionEvm.methods.addToCollectionAllowListCross(userCross).send({from: owner}); + await collectionEvm.methods.addToCollectionAllowListCross(user).send({from: owner}); await collectionEvm.methods.setCollectionMintMode(true).send({from: owner}); + await collectionEvm.methods.setCollectionLimit({field: CollectionLimitField.SponsoredDataRateLimit, value: {status: true, value: 30}}).send(); const newPermissions = (await collectionSub.getData())!.raw.permissions; expect(newPermissions.mintMode).to.be.true; expect(newPermissions.access).to.be.equal('AllowList'); + // Set token permissions + await collectionEvm.methods.setTokenPropertyPermissions([ + ['key', [ + [TokenPermissionField.TokenOwner, true], + ], + ], + ]).send({from: owner}); + const ownerBalanceBefore = await helper.balance.getSubstrate(helper.address.ethToSubstrate(owner)); const sponsorBalanceBefore = await helper.balance.getSubstrate(helper.address.ethToSubstrate(sponsorEth)); - const userBalanceBefore = await helper.balance.getSubstrate(helper.address.ethToSubstrate(user)); + const userBalanceBefore = await helper.balance.getSubstrate(helper.address.ethToSubstrate(user.eth)); // User can mint token without balance: { - const result = await collectionEvm.methods.mintWithTokenURI(user, 'Test URI').send({from: user}); + const result = await collectionEvm.methods.mintCross(user, [{key: 'key', value: Buffer.from('Value')}]).send({from: user.eth}); const event = helper.eth.normalizeEvents(result.events) .find(event => event.event === 'Transfer'); @@ -171,22 +180,102 @@ event: 'Transfer', args: { from: '0x0000000000000000000000000000000000000000', - to: user, + to: user.eth, tokenId: '1', }, }); + // await collectionEvm.methods.setProperties(1, [{key: 'key', value: Buffer.from('Value1')}]).send({from: user.eth}); + const ownerBalanceAfter = await helper.balance.getSubstrate(helper.address.ethToSubstrate(owner)); const sponsorBalanceAfter = await helper.balance.getSubstrate(helper.address.ethToSubstrate(sponsorEth)); - const userBalanceAfter = await helper.balance.getSubstrate(helper.address.ethToSubstrate(user)); + const userBalanceAfter = await helper.balance.getSubstrate(helper.address.ethToSubstrate(user.eth)); - expect(await collectionEvm.methods.tokenURI(nextTokenId).call()).to.be.equal('Test URI'); + expect(await collectionEvm.methods.properties(nextTokenId, []).call()) + .to.be.like([ + [ + 'key', + '0x' + Buffer.from('Value').toString('hex'), + ], + ]); expect(ownerBalanceBefore).to.be.eq(ownerBalanceAfter); expect(userBalanceAfter).to.be.eq(userBalanceBefore); expect(sponsorBalanceBefore > sponsorBalanceAfter).to.be.true; } })); + itEth('Can sponsor [set token properties] via access list', async ({helper}) => { + const owner = await helper.eth.createAccountWithBalance(donor); + const sponsorEth = await helper.eth.createAccountWithBalance(donor); + const sponsorCrossEth = helper.ethCrossAccount.fromAddress(sponsorEth); + + const {collectionAddress} = await helper.eth.createERC721MetadataCompatibleNFTCollection(owner, 'Sponsor collection', '1', '1', ''); + const collectionEvm = await helper.ethNativeContract.collection(collectionAddress, 'nft', owner, false); + + // Set collection sponsor: + await collectionEvm.methods.setCollectionSponsorCross(sponsorCrossEth).send({from: owner}); + + // Sponsor can confirm sponsorship: + await collectionEvm.methods.confirmCollectionSponsorship().send({from: sponsorEth}); + + // Create user with no balance: + const user = helper.ethCrossAccount.createAccount(); + const nextTokenId = await collectionEvm.methods.nextTokenId().call(); + expect(nextTokenId).to.be.equal('1'); + + // Set collection permissions: + await collectionEvm.methods.setCollectionAccess(1 /*'AllowList'*/).send({from: owner}); + await collectionEvm.methods.addToCollectionAllowListCross(user).send({from: owner}); + await collectionEvm.methods.setCollectionMintMode(true).send({from: owner}); + await collectionEvm.methods.setCollectionLimit({field: CollectionLimitField.SponsoredDataRateLimit, value: {status: true, value: 30}}).send(); + + // Set token permissions + await collectionEvm.methods.setTokenPropertyPermissions([ + ['key', [ + [TokenPermissionField.TokenOwner, true], + ], + ], + ]).send({from: owner}); + + const ownerBalanceBefore = await helper.balance.getSubstrate(helper.address.ethToSubstrate(owner)); + const sponsorBalanceBefore = await helper.balance.getSubstrate(helper.address.ethToSubstrate(sponsorEth)); + const userBalanceBefore = await helper.balance.getSubstrate(helper.address.ethToSubstrate(user.eth)); + + // User can mint token without balance: + { + const result = await collectionEvm.methods.mintCross(user, []).send({from: user.eth}); + const event = helper.eth.normalizeEvents(result.events) + .find(event => event.event === 'Transfer'); + + expect(event).to.be.deep.equal({ + address: collectionAddress, + event: 'Transfer', + args: { + from: '0x0000000000000000000000000000000000000000', + to: user.eth, + tokenId: '1', + }, + }); + + await collectionEvm.methods.setProperties(1, [{key: 'key', value: Buffer.from('Value')}]).send({from: user.eth}); + + const ownerBalanceAfter = await helper.balance.getSubstrate(helper.address.ethToSubstrate(owner)); + const sponsorBalanceAfter = await helper.balance.getSubstrate(helper.address.ethToSubstrate(sponsorEth)); + const userBalanceAfter = await helper.balance.getSubstrate(helper.address.ethToSubstrate(user.eth)); + + expect(await collectionEvm.methods.properties(nextTokenId, []).call()) + .to.be.like([ + [ + 'key', + '0x' + Buffer.from('Value').toString('hex'), + ], + ]); + expect(ownerBalanceBefore).to.be.eq(ownerBalanceAfter); + expect(userBalanceAfter).to.be.eq(userBalanceBefore); + expect(sponsorBalanceBefore > sponsorBalanceAfter).to.be.true; + } + }); + // TODO: Temprorary off. Need refactor // itWeb3('Sponsoring collection from substrate address via access list', async ({api, web3, privateKeyWrapper}) => { // const owner = await createEthAccountWithBalance(api, web3, privateKeyWrapper); @@ -456,6 +545,15 @@ expect(newPermissions.mintMode).to.be.true; expect(newPermissions.access).to.be.equal('AllowList'); + // Set token permissions + await collectionEvm.methods.setTokenPropertyPermissions([ + ['URI', [ + [TokenPermissionField.TokenOwner, true], + [TokenPermissionField.CollectionAdmin, true], + ], + ], + ]).send({from: owner}); + const ownerBalanceBefore = await helper.balance.getSubstrate(helper.address.ethToSubstrate(owner)); const sponsorBalanceBefore = await helper.balance.getSubstrate(helper.address.ethToSubstrate(sponsorEth)); const userBalanceBefore = await helper.balance.getSubstrate(helper.address.ethToSubstrate(user)); @@ -623,6 +721,15 @@ await collectionEvm.methods.addToCollectionAllowListCross(userCross).send({from: owner}); await collectionEvm.methods.setCollectionMintMode(true).send({from: owner}); + // Set token permissions + await collectionEvm.methods.setTokenPropertyPermissions([ + ['URI', [ + [TokenPermissionField.TokenOwner, true], + [TokenPermissionField.CollectionAdmin, true], + ], + ], + ]).send({from: owner}); + const ownerBalanceBefore = await helper.balance.getSubstrate(helper.address.ethToSubstrate(owner)); const sponsorBalanceBefore = await helper.balance.getSubstrate(sponsor.address); const userBalanceBefore = await helper.balance.getSubstrate(helper.address.ethToSubstrate(user)); --- a/tests/src/eth/tokenProperties.test.ts +++ b/tests/src/eth/tokenProperties.test.ts @@ -553,6 +553,63 @@ ]).call({from: owner})).to.be.rejectedWith('NoPermission'); } })); + + [ + {mode: 'nft' as const, requiredPallets: []}, + {mode: 'rft' as const, requiredPallets: [Pallets.ReFungible]}, + ].map(testCase => + itEth.ifWithPallets(`[${testCase.mode}] Can't be multiple set/read for non-existent token`, testCase.requiredPallets, async({helper}) => { + const caller = await helper.eth.createAccountWithBalance(donor); + + const properties = Array(5).fill(0).map((_, i) => { return {key: `key_${i}`, value: Buffer.from(`value_${i}`)}; }); + const permissions: ITokenPropertyPermission[] = properties.map(p => { return {key: p.key, permission: {tokenOwner: true, + collectionAdmin: true, + mutable: true}}; }); + + const collection = await helper[testCase.mode].mintCollection(alice, { + tokenPrefix: 'ethp', + tokenPropertyPermissions: permissions, + }) as UniqueNFTCollection | UniqueRFTCollection; + + await collection.addAdmin(alice, {Ethereum: caller}); + + const address = helper.ethAddress.fromCollectionId(collection.collectionId); + const contract = await helper.ethNativeContract.collection(address, testCase.mode, caller); + + await expect(contract.methods.setProperties(1, properties).call({from: caller})).to.be.rejectedWith('TokenNotFound'); + })); + + [ + {mode: 'nft' as const, requiredPallets: []}, + {mode: 'rft' as const, requiredPallets: [Pallets.ReFungible]}, + ].map(testCase => + itEth.ifWithPallets(`[${testCase.mode}] Can't be deleted for non-existent token`, testCase.requiredPallets, async({helper}) => { + const caller = await helper.eth.createAccountWithBalance(donor); + const collection = await helper[testCase.mode].mintCollection(alice, { + tokenPropertyPermissions: [{ + key: 'testKey', + permission: { + mutable: true, + collectionAdmin: true, + }, + }, + { + key: 'testKey_1', + permission: { + mutable: true, + collectionAdmin: true, + }, + }], + }); + + + await collection.addAdmin(alice, {Ethereum: caller}); + + const address = helper.ethAddress.fromCollectionId(collection.collectionId); + const contract = await helper.ethNativeContract.collection(address, testCase.mode, caller); + + await expect(contract.methods.deleteProperties(1, ['testKey', 'testKey_1']).call({from: caller})).to.be.rejectedWith('TokenNotFound'); + })); }); --- a/tests/src/getPropertiesRpc.test.ts +++ b/tests/src/getPropertiesRpc.test.ts @@ -120,3 +120,31 @@ expect(propPermissions).to.be.deep.equal(tokenPropPermissions); }); }); + +[ + {mode: 'nft' as const}, + {mode: 'rft' as const}, +].map(testCase => + describe('negative properties', () => { + let alice: IKeyringPair; + + before(async () => { + await usingPlaygrounds(async (_, privateKey) => { + alice = await privateKey({url: import.meta.url}); + }); + }); + + itSub(`[${testCase.mode}] set token property for non-existent token`, async ({helper}) => { + const collection = await helper[testCase.mode].mintCollection(alice); + await collection.setTokenPropertyPermissions(alice, [{key: 'key', permission: {mutable: true, tokenOwner: true, collectionAdmin: true}}]); + await expect(collection.setTokenProperties(alice, 1, [{key: 'key', value: 'value'}])).to.be.rejectedWith('common.TokenNotFound'); + expect(await collection.getTokenProperties(1, ['key'])).to.be.empty; + }); + + itSub(`[${testCase.mode}] delete token property for non-existent token`, async ({helper}) => { + const collection = await helper[testCase.mode].mintCollection(alice); + await collection.setTokenPropertyPermissions(alice, [{key: 'key', permission: {mutable: true, tokenOwner: true, collectionAdmin: true}}]); + await expect(collection.deleteTokenProperties(alice, 1, ['key'])).to.be.rejectedWith('common.TokenNotFound'); + expect(await collection.getTokenProperties(1, ['key'])).to.be.empty; + }); + })); \ No newline at end of file --- a/tests/src/nesting/tokenProperties.test.ts +++ b/tests/src/nesting/tokenProperties.test.ts @@ -448,6 +448,29 @@ expectedConsumedSpaceDiff = sizeOfProperty(biggerProp) - sizeOfProperty(smallerProp); expect(consumedSpace).to.be.equal(sizeOfProperty(biggerProp) - expectedConsumedSpaceDiff); })); + + itSub('Set sponsored properties', async({helper}) => { + const collection = await helper.nft.mintCollection(alice, {tokenPropertyPermissions: [{key: 'k', permission: {tokenOwner: true}}]}); + + await collection.setSponsor(alice, alice.address); + await collection.confirmSponsorship(alice); + await collection.setPermissions(alice, {access: 'AllowList', mintMode: true}); + await collection.addToAllowList(alice, {Substrate: bob.address}); + await collection.setLimits(alice, {sponsoredDataRateLimit: {blocks: 30}}); + + const token = await collection.mintToken(alice, {Substrate: bob.address}); + + const aliceBalanceBefore = await helper.balance.getSubstrate(alice.address); + const bobBalanceBefore = await helper.balance.getSubstrate(bob.address); + + await token.setProperties(bob, [{key: 'k', value: 'val'}]); + + const aliceBalanceAfter = await helper.balance.getSubstrate(alice.address); + const bobBalanceAfter = await helper.balance.getSubstrate(bob.address); + + expect(bobBalanceAfter).to.be.equal(bobBalanceBefore); + expect(aliceBalanceBefore > aliceBalanceAfter).to.be.true; + }); }); describe('Negative Integration Test: Token Properties', () => { @@ -475,6 +498,27 @@ }); }); + [ + {mode: 'nft' as const, requiredPallets: [Pallets.NFT]}, + {mode: 'rft' as const, requiredPallets: [Pallets.ReFungible]}, + ].map(testCase => + itSub.ifWithPallets(`Forbids adding/deleting properties of a token if token doesn't exist (${testCase.mode.toLocaleUpperCase})`, testCase.requiredPallets, async({helper}) => { + const collection = await helper[testCase.mode].mintCollection(alice, { + tokenPropertyPermissions: constitution.slice(0, 1).map(({permission}) => ({key: '1', permission})), + }); + const nonExistentToken = collection.getTokenObject(1); + + await expect( + nonExistentToken.setProperties(alice, [{key: '1', value: 'Serotonin increase'}]), + 'on expecting failure whilst adding a property by alice', + ).to.be.rejectedWith(/common\.TokenNotFound/); + + await expect( + nonExistentToken.deleteProperties(alice, ['1']), + 'on expecting failure whilst deleting a property by alice', + ).to.be.rejectedWith(/common\.TokenNotFound/); + })); + async function mintCollectionWithAllPermissionsAndToken(helper: UniqueHelper, mode: 'NFT' | 'RFT'): Promise<[UniqueNFToken | UniqueRFToken, bigint]> { const collection = await (mode == 'NFT' ? helper.nft : helper.rft).mintCollection(alice, { tokenPropertyPermissions: constitution.map(({permission}, i) => ({key: `${i+1}`, permission})),