--- a/node/cli/src/chain_spec.rs +++ b/node/cli/src/chain_spec.rs @@ -196,6 +196,7 @@ const_on_chain_schema: vec![], variable_on_chain_schema: vec![], limits: CollectionLimits::default(), + meta_update_permission: MetaUpdatePermission::ItemOwner, transfers_enabled: true, }, )], --- a/pallets/nft/src/lib.rs +++ b/pallets/nft/src/lib.rs @@ -43,7 +43,7 @@ OFFCHAIN_SCHEMA_LIMIT, MAX_TOKEN_PREFIX_LENGTH, MAX_COLLECTION_NAME_LENGTH, MAX_COLLECTION_DESCRIPTION_LENGTH, AccessMode, Collection, CreateItemData, CollectionLimits, CollectionId, CollectionMode, TokenId, SchemaVersion, SponsorshipState, Ownership, NftItemType, - FungibleItemType, ReFungibleItemType, + MetaUpdatePermission, FungibleItemType, ReFungibleItemType, }; #[cfg(test)] @@ -145,6 +145,10 @@ BadCreateRefungibleCall, /// Gas limit exceeded OutOfGas, + /// Metadata update denied by collection settings + MetadataUpdateDenied, + /// Metadata update flag become unmutable with None option + MetadataFlagFrozen, /// Collection settings not allowing items transferring TransferNotAllowed, /// Can't transfer tokens to ethereum zero address @@ -541,6 +545,7 @@ variable_on_chain_schema: Vec::new(), const_on_chain_schema: Vec::new(), limits, + meta_update_permission: MetaUpdatePermission::default(), transfers_enabled: true, }; @@ -940,6 +945,36 @@ target_collection.save() } + // TODO! transaction weight + /// Set meta_update_permission value for particular collection + /// + /// # Permissions + /// + /// * Collection Owner. + /// + /// # Arguments + /// + /// * collection_id: ID of the collection. + /// + /// * value: New flag value. + #[weight = ::WeightInfo::burn_item()] + #[transactional] + pub fn set_meta_update_permission_flag(origin, collection_id: CollectionId, value: MetaUpdatePermission) -> DispatchResult { + + let sender = ensure_signed(origin)?; + let mut target_collection = Self::get_collection(collection_id)?; + + ensure!( + target_collection.meta_update_permission != MetaUpdatePermission::None, + Error::::MetadataFlagFrozen + ); + Self::check_owner_permissions(&target_collection, &sender)?; + + target_collection.meta_update_permission = value; + + target_collection.save() + } + /// Destroys a concrete instance of NFT. /// /// # Permissions @@ -1485,10 +1520,11 @@ Error::::TokenVariableDataLimitExceeded ); - // Modify permissions check ensure!( - Self::is_item_owner(sender, collection, item_id)? - || Self::is_owner_or_admin_permissions(collection, sender)?, + (Self::is_item_owner(sender, collection, item_id)? + && collection.meta_update_permission == MetaUpdatePermission::ItemOwner) + || (Self::is_owner_or_admin_permissions(collection, sender)? + && collection.meta_update_permission == MetaUpdatePermission::Admin), Error::::NoPermission ); @@ -1504,6 +1540,26 @@ Ok(()) } + pub fn meta_update_check( + sender: &T::CrossAccountId, + collection: &CollectionHandle, + item_id: TokenId, + ) -> DispatchResult { + match collection.meta_update_permission { + MetaUpdatePermission::ItemOwner => ensure!( + Self::is_item_owner(sender, collection, item_id)?, + Error::::NoPermission + ), + MetaUpdatePermission::Admin => ensure!( + Self::is_owner_or_admin_permissions(collection, sender)?, + Error::::NoPermission + ), + MetaUpdatePermission::None => fail!(Error::::MetadataUpdateDenied), + } + + Ok(()) + } + pub fn get_variable_metadata( collection: &CollectionHandle, item_id: TokenId, --- a/pallets/nft/src/tests.rs +++ b/pallets/nft/src/tests.rs @@ -2005,56 +2005,285 @@ } #[test] -fn collection_transfer_flag_works() { +fn set_variable_meta_data_on_nft_with_item_owner_permission_flag() { new_test_ext().execute_with(|| { - let origin1 = Origin::signed(1); + //default_limits(); let collection_id = create_test_collection(&CollectionMode::NFT, 1); - assert_ok!(TemplateModule::set_transfers_enabled_flag(origin1, 1, true)); + let origin1 = Origin::signed(1); + let data = default_nft_data(); - create_test_item(collection_id, &data.into()); - assert_eq!(TemplateModule::balance_count(1, 1), 1); - assert_eq!(TemplateModule::address_tokens(1, 1), [1]); + create_test_item(1, &data.into()); - let origin1 = Origin::signed(1); + TemplateModule::set_meta_update_permission_flag( + origin1.clone(), + collection_id, + MetaUpdatePermission::ItemOwner, + ); - // default scenario - assert_ok!(TemplateModule::transfer(origin1, account(2), 1, 1, 1000)); - assert_eq!(TemplateModule::nft_item_id(1, 1).unwrap().owner, account(2)); - assert_eq!(TemplateModule::balance_count(1, 1), 0); - assert_eq!(TemplateModule::balance_count(1, 2), 1); + let variable_data = b"ten chars.".to_vec(); + assert_ok!(TemplateModule::set_variable_meta_data( + origin1, + collection_id, + 1, + variable_data.clone() + )); - assert_eq!(TemplateModule::address_tokens(1, 2), [1]); + assert_eq!( + TemplateModule::nft_item_id(collection_id, 1) + .unwrap() + .variable_data, + variable_data + ); }); } #[test] -fn collection_transfer_flag_works_neg() { +fn set_variable_meta_data_on_nft_with_item_owner_permission_flag_neg() { new_test_ext().execute_with(|| { + // default_limits(); + + let collection_id = create_test_collection_for_owner(&CollectionMode::NFT, 2, 1); + let origin1 = Origin::signed(1); + let origin2 = Origin::signed(2); - let collection_id = create_test_collection(&CollectionMode::NFT, 1); - assert_ok!(TemplateModule::set_transfers_enabled_flag( - origin1, 1, false + assert_ok!(TemplateModule::set_mint_permission( + origin2.clone(), + collection_id, + true + )); + assert_ok!(TemplateModule::add_to_white_list( + origin2.clone(), + collection_id, + account(1) )); let data = default_nft_data(); - create_test_item(collection_id, &data.into()); - assert_eq!(TemplateModule::balance_count(1, 1), 1); - assert_eq!(TemplateModule::address_tokens(1, 1), [1]); + create_test_item(1, &data.into()); - let origin1 = Origin::signed(1); + assert_ok!(TemplateModule::set_meta_update_permission_flag( + origin2.clone(), + collection_id, + MetaUpdatePermission::ItemOwner, + )); - // default scenario + let variable_data = b"ten chars.++".to_vec(); assert_noop!( - TemplateModule::transfer(origin1, account(2), 1, 1, 1000), - Error::::TransferNotAllowed + TemplateModule::set_variable_meta_data( + origin2, + collection_id, + 1, + variable_data.clone() + ), + Error::::TokenVariableDataLimitExceeded ); - assert_eq!(TemplateModule::nft_item_id(1, 1).unwrap().owner, account(1)); - assert_eq!(TemplateModule::balance_count(1, 1), 1); - assert_eq!(TemplateModule::balance_count(1, 2), 0); - assert_eq!(TemplateModule::address_tokens(1, 1), [1]); + #[test] + fn collection_transfer_flag_works() { + new_test_ext().execute_with(|| { + let origin1 = Origin::signed(1); + + let collection_id = create_test_collection(&CollectionMode::NFT, 1); + assert_ok!(TemplateModule::set_transfers_enabled_flag(origin1, 1, true)); + + let data = default_nft_data(); + create_test_item(collection_id, &data.into()); + assert_eq!(TemplateModule::balance_count(1, 1), 1); + assert_eq!(TemplateModule::address_tokens(1, 1), [1]); + + let origin1 = Origin::signed(1); + + // default scenario + assert_ok!(TemplateModule::transfer(origin1, account(2), 1, 1, 1000)); + assert_eq!(TemplateModule::nft_item_id(1, 1).unwrap().owner, account(2)); + assert_eq!(TemplateModule::balance_count(1, 1), 0); + assert_eq!(TemplateModule::balance_count(1, 2), 1); + + assert_eq!(TemplateModule::address_tokens(1, 2), [1]); + }); + } + + #[test] + fn set_variable_meta_data_on_nft_with_admin_flag() { + new_test_ext().execute_with(|| { + // default_limits(); + + let collection_id = create_test_collection_for_owner(&CollectionMode::NFT, 2, 1); + + let origin1 = Origin::signed(1); + let origin2 = Origin::signed(2); + + assert_ok!(TemplateModule::set_mint_permission( + origin2.clone(), + collection_id, + true + )); + assert_ok!(TemplateModule::add_to_white_list( + origin2.clone(), + collection_id, + account(1) + )); + + assert_ok!(TemplateModule::add_collection_admin( + origin2.clone(), + collection_id, + account(1) + )); + + let data = default_nft_data(); + create_test_item(1, &data.into()); + + assert_ok!(TemplateModule::set_meta_update_permission_flag( + origin2.clone(), + collection_id, + MetaUpdatePermission::Admin, + )); + + let variable_data = b"test set_variable_meta_data method.".to_vec(); + assert_ok!(TemplateModule::set_variable_meta_data( + origin1, + collection_id, + 1, + variable_data.clone() + )); + + assert_eq!( + TemplateModule::nft_item_id(collection_id, 1) + .unwrap() + .variable_data, + variable_data + ); + }); + } + + #[test] + fn set_variable_meta_data_on_nft_with_admin_flag_neg() { + new_test_ext().execute_with(|| { + // default_limits(); + + let collection_id = create_test_collection_for_owner(&CollectionMode::NFT, 2, 1); + + let origin1 = Origin::signed(1); + let origin2 = Origin::signed(2); + + assert_ok!(TemplateModule::set_mint_permission( + origin2.clone(), + collection_id, + true + )); + assert_ok!(TemplateModule::add_to_white_list( + origin2.clone(), + collection_id, + account(1) + )); + + let data = default_nft_data(); + create_test_item(1, &data.into()); + + assert_ok!(TemplateModule::set_meta_update_permission_flag( + origin2.clone(), + collection_id, + MetaUpdatePermission::Admin, + )); + + let variable_data = b"test set_variable_meta_data method.".to_vec(); + assert_noop!( + TemplateModule::set_variable_meta_data( + origin1, + collection_id, + 1, + variable_data.clone() + ), + Error::::NoPermission + ); + }); + } + + #[test] + fn set_variable_meta_flag_after_freeze() { + new_test_ext().execute_with(|| { + // default_limits(); + + let collection_id = create_test_collection_for_owner(&CollectionMode::NFT, 2, 1); + + let origin2 = Origin::signed(2); + + assert_ok!(TemplateModule::set_meta_update_permission_flag( + origin2.clone(), + collection_id, + MetaUpdatePermission::None, + )); + assert_noop!( + TemplateModule::set_meta_update_permission_flag( + origin2.clone(), + collection_id, + MetaUpdatePermission::Admin + ), + Error::::MetadataFlagFrozen + ); + }); + } + + #[test] + fn set_variable_meta_data_on_nft_with_none_flag_neg() { + new_test_ext().execute_with(|| { + // default_limits(); + + let collection_id = create_test_collection_for_owner(&CollectionMode::NFT, 1, 1); + let origin1 = Origin::signed(1); + + let data = default_nft_data(); + create_test_item(1, &data.into()); + + assert_ok!(TemplateModule::set_meta_update_permission_flag( + origin1.clone(), + collection_id, + MetaUpdatePermission::None, + )); + + let variable_data = b"test set_variable_meta_data method.".to_vec(); + assert_noop!( + TemplateModule::set_variable_meta_data( + origin1.clone(), + collection_id, + 1, + variable_data.clone() + ), + Error::::MetadataUpdateDenied + ); + }); + } + + #[test] + fn collection_transfer_flag_works_neg() { + new_test_ext().execute_with(|| { + let origin1 = Origin::signed(1); + + let collection_id = create_test_collection(&CollectionMode::NFT, 1); + assert_ok!(TemplateModule::set_transfers_enabled_flag( + origin1, 1, false + )); + + let data = default_nft_data(); + create_test_item(collection_id, &data.into()); + assert_eq!(TemplateModule::balance_count(1, 1), 1); + assert_eq!(TemplateModule::address_tokens(1, 1), [1]); + + let origin1 = Origin::signed(1); + + // default scenario + assert_noop!( + TemplateModule::transfer(origin1, account(2), 1, 1, 1000), + Error::::TransferNotAllowed + ); + assert_eq!(TemplateModule::nft_item_id(1, 1).unwrap().owner, account(1)); + assert_eq!(TemplateModule::balance_count(1, 1), 1); + assert_eq!(TemplateModule::balance_count(1, 2), 0); + + assert_eq!(TemplateModule::address_tokens(1, 1), [1]); + }); + } }); } --- a/primitives/nft/src/lib.rs +++ b/primitives/nft/src/lib.rs @@ -185,6 +185,7 @@ pub limits: CollectionLimits, // Collection private restrictions pub variable_on_chain_schema: Vec, // pub const_on_chain_schema: Vec, // + pub meta_update_permission: MetaUpdatePermission, pub transfers_enabled: bool, } @@ -305,6 +306,20 @@ pub pieces: u128, } +#[derive(Encode, Decode, Debug, Clone, PartialEq)] +#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +pub enum MetaUpdatePermission { + ItemOwner, + Admin, + None, +} + +impl Default for MetaUpdatePermission { + fn default() -> Self { + Self::ItemOwner + } +} + #[derive(Encode, Decode, MaxEncodedLen, PartialEq, Clone, Debug)] #[cfg_attr(feature = "serde1", derive(Serialize, Deserialize))] pub enum CreateItemData { --- a/runtime_types.json +++ b/runtime_types.json @@ -63,6 +63,7 @@ "Limits": "CollectionLimits", "VariableOnChainSchema": "Vec", "ConstOnChainSchema": "Vec", + "MetaUpdatePermission": "MetaUpdatePermission", "TransfersEnabled": "bool" }, "RawData": "Vec", @@ -94,6 +95,13 @@ "Unique" ] }, + "MetaUpdatePermission": { + "_enum": [ + "ItemOwner", + "Admin", + "None" + ] + }, "CollectionId": "u32", "TokenId": "u32", "ChainLimits": { --- a/tests/src/addCollectionAdmin.test.ts +++ b/tests/src/addCollectionAdmin.test.ts @@ -4,7 +4,6 @@ // import { ApiPromise } from '@polkadot/api'; -import BN from 'bn.js'; import chai from 'chai'; import chaiAsPromised from 'chai-as-promised'; import privateKey from './substrate/privateKey'; --- a/tests/src/collision-tests/adminLimitsOff.test.ts +++ b/tests/src/collision-tests/adminLimitsOff.test.ts @@ -1,5 +1,4 @@ import { IKeyringPair } from '@polkadot/types/types'; -import BN from 'bn.js'; import chai from 'chai'; import chaiAsPromised from 'chai-as-promised'; import privateKey from '../substrate/privateKey'; --- /dev/null +++ b/tests/src/metadataUpdate.test.ts @@ -0,0 +1,204 @@ + +// +// This file is subject to the terms and conditions defined in +// file 'LICENSE', which is part of this source code package. +// + +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import privateKey from './substrate/privateKey'; +import usingApi from './substrate/substrate-api'; +import { + createItemExpectSuccess, + createCollectionExpectSuccess, + enablePublicMintingExpectSuccess, + enableWhiteListExpectSuccess, + setMetadataUpdatePermissionFlagExpectSuccess, + setVariableMetaDataExpectSuccess, + setMintPermissionExpectSuccess, + addToWhiteListExpectSuccess, + addCollectionAdminExpectSuccess, + setVariableMetaDataExpectFailure, + setMetadataUpdatePermissionFlagExpectFailure, +} from './util/helpers'; + +chai.use(chaiAsPromised); + +describe('Metadata update permissions with ItemOwner flag', () => { + it('ItemOwner can set variable metadata with ItemOwner permission flag', async () => { + await usingApi(async () => { + const Alice = privateKey('//Alice'); + + const data = [1, 2, 254, 255]; + + // nft + const nftCollectionId = await createCollectionExpectSuccess(); + const newNftTokenId = await createItemExpectSuccess(Alice, nftCollectionId, 'NFT'); + await setMetadataUpdatePermissionFlagExpectSuccess(Alice, nftCollectionId, 'ItemOwner'); + + await setVariableMetaDataExpectSuccess(Alice, nftCollectionId, newNftTokenId, data); + }); + }); + + it('Admin can\'n set variable metadata with ItemOwner permission flag', async () => { + await usingApi(async () => { + const Alice = privateKey('//Alice'); + const Bob = privateKey('//Bob'); + + const data = [1, 2, 254, 255]; + + // nft + const nftCollectionId = await createCollectionExpectSuccess(); + const newNftTokenId = await createItemExpectSuccess(Alice, nftCollectionId, 'NFT'); + await setMetadataUpdatePermissionFlagExpectSuccess(Alice, nftCollectionId, 'ItemOwner'); + + await setMintPermissionExpectSuccess(Alice, nftCollectionId, true); + await addToWhiteListExpectSuccess(Alice, nftCollectionId, Bob.address); + await addCollectionAdminExpectSuccess(Alice, nftCollectionId, Bob); + + await setVariableMetaDataExpectFailure(Bob, nftCollectionId, newNftTokenId, data); + }); + }); + + it('User can\'n set variable metadata with ItemOwner permission flag', async () => { + await usingApi(async () => { + const Alice = privateKey('//Alice'); + const Bob = privateKey('//Bob'); + + const data = [1, 2, 254, 255]; + + // nft + const nftCollectionId = await createCollectionExpectSuccess(); + const newNftTokenId = await createItemExpectSuccess(Alice, nftCollectionId, 'NFT'); + await setMetadataUpdatePermissionFlagExpectSuccess(Alice, nftCollectionId, 'ItemOwner'); + + await setMintPermissionExpectSuccess(Alice, nftCollectionId, true); + await setVariableMetaDataExpectFailure(Bob, nftCollectionId, newNftTokenId, data); + }); + }); +}); + +describe('Metadata update permissions with Admin flag', () => { + it('Admin can set variable metadata with Admin permission flag', async () => { + await usingApi(async () => { + const Alice = privateKey('//Alice'); + const Bob = privateKey('//Bob'); + + const data = [1, 2, 254, 255]; + + // nft + const nftCollectionId = await createCollectionExpectSuccess(); + const newNftTokenId = await createItemExpectSuccess(Alice, nftCollectionId, 'NFT'); + await setMetadataUpdatePermissionFlagExpectSuccess(Alice, nftCollectionId, 'Admin'); + + await setMintPermissionExpectSuccess(Alice, nftCollectionId, true); + await addToWhiteListExpectSuccess(Alice, nftCollectionId, Bob.address); + await addCollectionAdminExpectSuccess(Alice, nftCollectionId, Bob); + + await setVariableMetaDataExpectSuccess(Bob, nftCollectionId, newNftTokenId, data); + }); + }); + + it('User can\'n can set variable metadata with Admin permission flag', async () => { + await usingApi(async () => { + const Alice = privateKey('//Alice'); + const Bob = privateKey('//Bob'); + + const data = [1, 2, 254, 255]; + + // nft + const nftCollectionId = await createCollectionExpectSuccess(); + const newNftTokenId = await createItemExpectSuccess(Alice, nftCollectionId, 'NFT'); + await setMetadataUpdatePermissionFlagExpectSuccess(Alice, nftCollectionId, 'Admin'); + + await setMintPermissionExpectSuccess(Alice, nftCollectionId, true); + await addToWhiteListExpectSuccess(Alice, nftCollectionId, Bob.address); + await addCollectionAdminExpectSuccess(Alice, nftCollectionId, Bob); + + await setVariableMetaDataExpectSuccess(Bob, nftCollectionId, newNftTokenId, data); + }); + }); + + it('ItemOwner can\'n can set variable metadata with Admin permission flag', async () => { + await usingApi(async () => { + const Alice = privateKey('//Alice'); + const Bob = privateKey('//Bob'); + + const data = [1, 2, 254, 255]; + + // nft + const nftCollectionId = await createCollectionExpectSuccess(); + await enablePublicMintingExpectSuccess(Alice, nftCollectionId); + await addToWhiteListExpectSuccess(Alice, nftCollectionId, Bob.address); + await enableWhiteListExpectSuccess(Alice, nftCollectionId); + const newNftTokenId = await createItemExpectSuccess(Bob, nftCollectionId, 'NFT'); + await setMetadataUpdatePermissionFlagExpectSuccess(Alice, nftCollectionId, 'Admin'); + + await setVariableMetaDataExpectFailure(Bob, nftCollectionId, newNftTokenId, data); + }); + }); +}); + +describe('Metadata update permissions with None flag', () => { + it('Nobody can set variable metadata with None flag (Regular)', async () => { + await usingApi(async () => { + const Alice = privateKey('//Alice'); + const Bob = privateKey('//Bob'); + + const data = [1, 2, 254, 255]; + + // nft + const nftCollectionId = await createCollectionExpectSuccess(); + const newNftTokenId = await createItemExpectSuccess(Alice, nftCollectionId, 'NFT'); + await setMetadataUpdatePermissionFlagExpectSuccess(Alice, nftCollectionId, 'None'); + + await setVariableMetaDataExpectFailure(Bob, nftCollectionId, newNftTokenId, data); + }); + }); + + it('Nobody can set variable metadata with None flag (Admin)', async () => { + await usingApi(async () => { + const Alice = privateKey('//Alice'); + const Bob = privateKey('//Bob'); + + const data = [1, 2, 254, 255]; + + // nft + const nftCollectionId = await createCollectionExpectSuccess(); + const newNftTokenId = await createItemExpectSuccess(Alice, nftCollectionId, 'NFT'); + await setMetadataUpdatePermissionFlagExpectSuccess(Alice, nftCollectionId, 'None'); + + await setMintPermissionExpectSuccess(Alice, nftCollectionId, true); + await addToWhiteListExpectSuccess(Alice, nftCollectionId, Bob.address); + await addCollectionAdminExpectSuccess(Alice, nftCollectionId, Bob); + + await setVariableMetaDataExpectFailure(Bob, nftCollectionId, newNftTokenId, data); + }); + }); + + it('Nobody can set variable metadata with None flag (ItemOwner)', async () => { + await usingApi(async () => { + const Alice = privateKey('//Alice'); + + const data = [1, 2, 254, 255]; + + // nft + const nftCollectionId = await createCollectionExpectSuccess(); + const newNftTokenId = await createItemExpectSuccess(Alice, nftCollectionId, 'NFT'); + await setMetadataUpdatePermissionFlagExpectSuccess(Alice, nftCollectionId, 'None'); + + await setVariableMetaDataExpectFailure(Alice, nftCollectionId, newNftTokenId, data); + }); + }); + + it('Nobody can set variable metadata flag after freeze', async () => { + await usingApi(async () => { + const Alice = privateKey('//Alice'); + + // nft + const nftCollectionId = await createCollectionExpectSuccess(); + await setMetadataUpdatePermissionFlagExpectSuccess(Alice, nftCollectionId, 'None'); + await setMetadataUpdatePermissionFlagExpectFailure(Alice, nftCollectionId, 'Admin'); + }); + }); +}); --- a/tests/src/util/helpers.ts +++ b/tests/src/util/helpers.ts @@ -525,6 +525,28 @@ }); } +export async function setMetadataUpdatePermissionFlagExpectSuccess(sender: IKeyringPair, collectionId: number, flag: string) { + + await usingApi(async (api) => { + const tx = api.tx.nft.setMetaUpdatePermissionFlag(collectionId, flag); + const events = await submitTransactionAsync(sender, tx); + const result = getGenericResult(events); + + expect(result.success).to.be.true; + }); +} + +export async function setMetadataUpdatePermissionFlagExpectFailure(sender: IKeyringPair, collectionId: number, flag: string) { + + await usingApi(async (api) => { + const tx = api.tx.nft.setMetaUpdatePermissionFlag(collectionId, flag); + const events = await expect(submitTransactionExpectFailAsync(sender, tx)).to.be.rejected; + const result = getGenericResult(events); + + expect(result.success).to.be.false; + }); +} + export async function enableContractSponsoringExpectSuccess(sender: IKeyringPair, contractAddress: AccountId | string, enable: boolean) { await usingApi(async (api) => { const tx = api.tx.nft.enableContractSponsoring(contractAddress, enable);