diff --git a/pallets/rmrk-core/src/functions.rs b/pallets/rmrk-core/src/functions.rs index 004c10a7..308e343a 100644 --- a/pallets/rmrk-core/src/functions.rs +++ b/pallets/rmrk-core/src/functions.rs @@ -80,6 +80,7 @@ where collection_id: CollectionId, nft_id: NftId, resource: ResourceTypes, BoundedVec>, + adding_on_mint: bool, ) -> Result { let collection = Self::collections(collection_id).ok_or(Error::::CollectionUnknown)?; let resource_id = Self::get_next_resource_id(collection_id, nft_id)?; @@ -102,10 +103,19 @@ where }, } + // Resource should be in a pending state if the rootowner of the resource is not the sender + // of the transaction, unless the resource is being added on mint. This prevents the + // situation where an NFT being minted *directly to* a non-owned NFT *with resources* will + // have those resources be *pending*. While the minted NFT itself will be pending, it is + // inefficent and unnecessary to have the resources also be pending. Otherwise, in such a + // case, the owner would have to accept not only the NFT but also all originally-added + // resources. + let pending = (root_owner != sender) && !adding_on_mint; + let res: ResourceInfo, BoundedVec> = ResourceInfo::, BoundedVec> { id: resource_id, - pending: root_owner != sender, + pending, pending_removal: false, resource, }; @@ -267,7 +277,7 @@ where type MaxRecursions = T::MaxRecursions; fn nft_mint( - _sender: T::AccountId, + sender: T::AccountId, owner: T::AccountId, collection_id: CollectionId, royalty_recipient: Option, @@ -283,6 +293,9 @@ where ensure!(nft_id < max, Error::::CollectionFullOrLocked); } + // NFT should be pending if minting to another account + let pending = owner != sender; + let mut royalty: Option> = None; if let Some(amount) = royalty_amount { @@ -291,20 +304,76 @@ where royalty = Some(RoyaltyInfo:: { recipient, amount }); }, None => { - royalty = - Some(RoyaltyInfo:: { recipient: owner.clone(), amount }); + // If a royalty amount is passed but no recipient, defaults to the sender + royalty = Some(RoyaltyInfo:: { recipient: sender, amount }); }, } }; - let owner_as_maybe_account = AccountIdOrCollectionNftTuple::AccountId(owner); + let nft = NftInfo { + owner: AccountIdOrCollectionNftTuple::AccountId(owner), + royalty, + metadata, + equipped: false, + pending, + transferable, + }; + + Nfts::::insert(collection_id, nft_id, nft); + + // increment nfts counter + let nfts_count = collection.nfts_count.checked_add(1).ok_or(ArithmeticError::Overflow)?; + Collections::::try_mutate(collection_id, |collection| -> DispatchResult { + let collection = collection.as_mut().ok_or(Error::::CollectionUnknown)?; + collection.nfts_count = nfts_count; + Ok(()) + })?; + + Ok((collection_id, nft_id)) + } + + fn nft_mint_directly_to_nft( + sender: T::AccountId, + owner: (CollectionId, NftId), + collection_id: CollectionId, + royalty_recipient: Option, + royalty_amount: Option, + metadata: StringLimitOf, + transferable: bool, + ) -> sp_std::result::Result<(CollectionId, NftId), DispatchError> { + let nft_id = Self::get_next_nft_id(collection_id)?; + let collection = Self::collections(collection_id).ok_or(Error::::CollectionUnknown)?; + + // Prevent minting when next NFT id is greater than the collection max. + if let Some(max) = collection.max { + ensure!(nft_id < max, Error::::CollectionFullOrLocked); + } + + // Calculate the rootowner of the intended owner of the minted NFT + let (rootowner, _) = Self::lookup_root_owner(owner.0, owner.1)?; + + // NFT should be pending if minting either to an NFT owned by another account + let pending = rootowner != sender; + + let mut royalty: Option> = None; + + if let Some(amount) = royalty_amount { + match royalty_recipient { + Some(recipient) => { + royalty = Some(RoyaltyInfo:: { recipient, amount }); + }, + None => { + royalty = Some(RoyaltyInfo:: { recipient: rootowner, amount }); + }, + } + }; let nft = NftInfo { - owner: owner_as_maybe_account, + owner: AccountIdOrCollectionNftTuple::CollectionAndNftTuple(owner.0, owner.1), royalty, metadata, equipped: false, - pending: false, + pending, transferable, }; diff --git a/pallets/rmrk-core/src/lib.rs b/pallets/rmrk-core/src/lib.rs index b1b68fe8..f71eb2e1 100644 --- a/pallets/rmrk-core/src/lib.rs +++ b/pallets/rmrk-core/src/lib.rs @@ -234,7 +234,7 @@ pub mod pallet { }, // NftMinted(T::AccountId, CollectionId, NftId), NftMinted { - owner: T::AccountId, + owner: AccountIdOrCollectionNftTuple, collection_id: CollectionId, nft_id: NftId, }, @@ -373,14 +373,15 @@ pub mod pallet { return Err(Error::::CollectionUnknown.into()) } - // Default owner to minter + // Extract intended owner or default to sender let nft_owner = match owner { Some(owner) => owner, None => sender.clone(), }; + // Mint NFT for RMRK storage let (collection_id, nft_id) = Self::nft_mint( - sender, + sender.clone(), nft_owner.clone(), collection_id, royalty_recipient, @@ -396,13 +397,87 @@ pub mod pallet { |_details| Ok(()), )?; + // Add all at-mint resources + if let Some(resources) = resources { + for res in resources { + Self::resource_add(sender.clone(), collection_id, nft_id, res, true)?; + } + } + + Self::deposit_event(Event::NftMinted { + owner: AccountIdOrCollectionNftTuple::AccountId(nft_owner), + collection_id, + nft_id, + }); + + Ok(()) + } + + /// Mints an NFT in the specified collection directly to another NFT + /// Sets metadata and the royalty attribute + /// + /// Parameters: + /// - `collection_id`: The class of the asset to be minted. + /// - `nft_id`: The nft value of the asset to be minted. + /// - `recipient`: Receiver of the royalty + /// - `royalty`: Permillage reward from each trade for the Recipient + /// - `metadata`: Arbitrary data about an nft, e.g. IPFS hash + #[pallet::weight(10_000 + T::DbWeight::get().reads_writes(1,1))] + #[transactional] + pub fn mint_nft_directly_to_nft( + origin: OriginFor, + owner: (CollectionId, NftId), + collection_id: CollectionId, + royalty_recipient: Option, + royalty: Option, + metadata: BoundedVec, + transferable: bool, + resources: Option>, + ) -> DispatchResult { + let sender = ensure_signed(origin.clone())?; + + // Collection must exist and sender must be issuer of collection + if let Some(collection_issuer) = + pallet_uniques::Pallet::::collection_owner(collection_id) + { + ensure!(collection_issuer == sender, Error::::NoPermission); + } else { + return Err(Error::::CollectionUnknown.into()) + } + + // Mint NFT for RMRK storage + let (collection_id, nft_id) = Self::nft_mint_directly_to_nft( + sender.clone(), + owner, + collection_id, + royalty_recipient, + royalty, + metadata, + transferable, + )?; + + // For Uniques, we need to decode the "virtual account" ID to be the owner + let uniques_owner = Self::nft_to_account_id(owner.0, owner.1); + + pallet_uniques::Pallet::::do_mint( + collection_id, + nft_id, + uniques_owner, + |_details| Ok(()), + )?; + + // Add all at-mint resources if let Some(resources) = resources { for res in resources { - Self::resource_add(nft_owner.clone(), collection_id, nft_id, res)?; + Self::resource_add(sender.clone(), collection_id, nft_id, res, true)?; } } - Self::deposit_event(Event::NftMinted { owner: nft_owner, collection_id, nft_id }); + Self::deposit_event(Event::NftMinted { + owner: AccountIdOrCollectionNftTuple::CollectionAndNftTuple(owner.0, owner.1), + collection_id, + nft_id, + }); Ok(()) } @@ -660,8 +735,13 @@ pub mod pallet { ) -> DispatchResult { let sender = ensure_signed(origin)?; - let resource_id = - Self::resource_add(sender, collection_id, nft_id, ResourceTypes::Basic(resource))?; + let resource_id = Self::resource_add( + sender, + collection_id, + nft_id, + ResourceTypes::Basic(resource), + false, + )?; Self::deposit_event(Event::ResourceAdded { nft_id, resource_id }); Ok(()) @@ -683,6 +763,7 @@ pub mod pallet { collection_id, nft_id, ResourceTypes::Composable(resource), + false, )?; Self::deposit_event(Event::ResourceAdded { nft_id, resource_id }); @@ -700,8 +781,13 @@ pub mod pallet { ) -> DispatchResult { let sender = ensure_signed(origin)?; - let resource_id = - Self::resource_add(sender, collection_id, nft_id, ResourceTypes::Slot(resource))?; + let resource_id = Self::resource_add( + sender, + collection_id, + nft_id, + ResourceTypes::Slot(resource), + false, + )?; Self::deposit_event(Event::ResourceAdded { nft_id, resource_id }); Ok(()) diff --git a/pallets/rmrk-core/src/tests.rs b/pallets/rmrk-core/src/tests.rs index bf17f55c..a9f71138 100644 --- a/pallets/rmrk-core/src/tests.rs +++ b/pallets/rmrk-core/src/tests.rs @@ -119,7 +119,7 @@ fn create_collection_no_max_works() { } // Last event should be the 100th NFT creation System::assert_last_event(MockEvent::RmrkCore(crate::Event::NftMinted { - owner: ALICE, + owner: AccountIdOrCollectionNftTuple::AccountId(ALICE), collection_id: 0, nft_id: 99, })); @@ -227,7 +227,7 @@ fn mint_nft_works() { assert_ok!(basic_mint()); // Minting an NFT should trigger an NftMinted event System::assert_last_event(MockEvent::RmrkCore(crate::Event::NftMinted { - owner: ALICE, + owner: AccountIdOrCollectionNftTuple::AccountId(ALICE), collection_id: 0, nft_id: 0, })); @@ -274,6 +274,120 @@ fn mint_nft_works() { }); } +/// NFT: Mint directly to NFT +#[test] +fn mint_directly_to_nft() { + ExtBuilder::default().build().execute_with(|| { + // Create a basic collection + assert_ok!(basic_collection()); + + // Mint directly to non-existent NFT fails + assert_noop!( + RMRKCore::mint_nft_directly_to_nft( + Origin::signed(ALICE), + (0, 0), + COLLECTION_ID_0, + None, + Some(Permill::from_float(20.525)), + bvec![0u8; 20], + true, + None, + ), + Error::::NoAvailableNftId + ); + + // ALICE mints an NFT for BOB + assert_ok!(RMRKCore::mint_nft( + Origin::signed(ALICE), + Some(BOB), + COLLECTION_ID_0, + None, + Some(Permill::from_float(20.525)), + bvec![0u8; 20], + true, + None, + )); + + // BOB owns NFT (0, 0) + assert_eq!( + RmrkCore::nfts(0, 0).unwrap().owner, + AccountIdOrCollectionNftTuple::AccountId(BOB) + ); + + // ALICE mints NFT directly to BOB-owned NFT (0, 0) + assert_ok!(RMRKCore::mint_nft_directly_to_nft( + Origin::signed(ALICE), + (0, 0), + COLLECTION_ID_0, + None, + Some(Permill::from_float(20.525)), + bvec![0u8; 20], + true, + None, + )); + + // Minted NFT (0, 1) exists + assert!(RmrkCore::nfts(0, 1).is_some()); + + // Minted NFT (0, 1) has owner NFT (0, 0) + assert_eq!( + RmrkCore::nfts(0, 1).unwrap().owner, + AccountIdOrCollectionNftTuple::CollectionAndNftTuple(0, 0) + ); + + // Minted NFT (0, 1) is pending + assert!(RmrkCore::nfts(0, 1).unwrap().pending); + }); +} + +/// NFT: When minting directly to a non-owned NFT *with resources*, the resources should *not* be +/// pending +#[test] +fn mint_directly_to_nft_with_resources() { + ExtBuilder::default().build().execute_with(|| { + // Create a basic collection + assert_ok!(basic_collection()); + + // ALICE mints an NFT for BOB + assert_ok!(RMRKCore::mint_nft( + Origin::signed(ALICE), + Some(BOB), + COLLECTION_ID_0, + None, + Some(Permill::from_float(20.525)), + bvec![0u8; 20], + true, + None, + )); + + // Compose a resource to add to an NFT + let basic_resource = + BasicResource { src: None, metadata: None, license: None, thumb: None }; + + // Construct as a BoundedVec of resources which mint_nft will accept + let resources_to_add = bvec![ResourceTypes::Basic(basic_resource)]; + + // ALICE mints NFT directly to BOB-owned NFT (0, 0), with the above resource + assert_ok!(RMRKCore::mint_nft_directly_to_nft( + Origin::signed(ALICE), + (0, 0), + COLLECTION_ID_0, + None, + Some(Permill::from_float(20.525)), + bvec![0u8; 20], + true, + Some(resources_to_add), + )); + + // Created resource 0 on NFT (0, 1) should exist + assert!(RmrkCore::resources((0, 1, 0)).is_some()); + + println!("{:?}", RmrkCore::resources((0, 1, 0)).unwrap()); + // Created resource 0 on NFT (0, 1) should not be pending + assert!(!RmrkCore::resources((0, 1, 0)).unwrap().pending); + }); +} + /// NFT: Mint tests with max (RMRK2.0 spec: MINT) #[test] fn mint_collection_max_logic_works() { @@ -302,7 +416,7 @@ fn royalty_recipient_default_works() { // Mint an NFT assert_ok!(RMRKCore::mint_nft( Origin::signed(ALICE), - None, + Some(ALICE), COLLECTION_ID_0, None, // No royalty recipient Some(Permill::from_float(20.525)), @@ -315,7 +429,7 @@ fn royalty_recipient_default_works() { // Mint another NFT assert_ok!(RMRKCore::mint_nft( Origin::signed(ALICE), - None, + Some(ALICE), COLLECTION_ID_0, Some(BOB), // Royalty recipient is BOB Some(Permill::from_float(20.525)), @@ -328,7 +442,7 @@ fn royalty_recipient_default_works() { // Mint another NFT assert_ok!(RMRKCore::mint_nft( Origin::signed(ALICE), - None, + Some(ALICE), COLLECTION_ID_0, None, // No royalty recipient is BOB None, // No royalty amount @@ -341,7 +455,7 @@ fn royalty_recipient_default_works() { // Mint another NFT assert_ok!(RMRKCore::mint_nft( Origin::signed(ALICE), - None, + Some(ALICE), COLLECTION_ID_0, Some(ALICE), // Royalty recipient is ALICE None, // No royalty amount @@ -513,7 +627,7 @@ fn send_non_transferable_fail() { // Mint non-transferable NFT assert_ok!(RMRKCore::mint_nft( Origin::signed(ALICE), - None, + Some(ALICE), COLLECTION_ID_0, Some(ALICE), Some(Permill::from_float(1.525)), @@ -533,6 +647,80 @@ fn send_non_transferable_fail() { }); } +#[test] +fn mint_non_transferrable_gem_on_to_nft_works() { + ExtBuilder::default().build().execute_with(|| { + // Create a basic collection + assert_ok!(basic_collection()); + + // Mint NFT (transferrable, will be the parent of a later-minted non-transferrable NFT) + assert_ok!(RMRKCore::mint_nft( + Origin::signed(ALICE), + Some(BOB), + COLLECTION_ID_0, + Some(ALICE), + Some(Permill::from_float(1.525)), + bvec![0u8; 20], + true, // transferable + None, + )); + + // Mint non-transferable NFT *on to* Bob-owned NFT (0, 0) + assert_ok!(RMRKCore::mint_nft_directly_to_nft( + Origin::signed(ALICE), + (0, 0), + COLLECTION_ID_0, + Some(ALICE), + Some(Permill::from_float(1.525)), + bvec![0u8; 20], + false, // non-transferable + None, + )); + + // NFT (0, 1) exists and is non-transferrable + assert!(!RMRKCore::nfts(0, 1).unwrap().transferable); + + // NFT (0, 1) is owned by BOB-owned NFT (0, 0) + assert_eq!( + RMRKCore::nfts(0, 1).unwrap().owner, + AccountIdOrCollectionNftTuple::CollectionAndNftTuple(0, 0) + ); + + // BOB *cannot send* non-transferrable NFT (0, 1) to CHARLIE + assert_noop!( + RMRKCore::send( + Origin::signed(BOB), + 0, + 1, + AccountIdOrCollectionNftTuple::AccountId(CHARLIE) + ), + Error::::NonTransferable + ); + + // BOB *cannot send* non-transferrable NFT (0, 1) to BOB (his own root account) + assert_noop!( + RMRKCore::send( + Origin::signed(BOB), + 0, + 1, + AccountIdOrCollectionNftTuple::AccountId(BOB) + ), + Error::::NonTransferable + ); + + // BOB *can* send NFT (0, 0) to CHARLIE + assert_ok!(RMRKCore::send( + Origin::signed(BOB), + 0, + 0, + AccountIdOrCollectionNftTuple::AccountId(CHARLIE) + )); + + // CHARLIE now rootowns NFT (0, 1) + assert_eq!(RMRKCore::lookup_root_owner(0, 1).unwrap().0, CHARLIE); + }); +} + /// NFT: Reject tests (RMRK2.0 spec: new) #[test] fn reject_nft_works() { @@ -1051,7 +1239,7 @@ fn add_resource_on_mint_works() { // Mint NFT assert_ok!(RMRKCore::mint_nft( Origin::signed(ALICE), - None, + Some(ALICE), COLLECTION_ID_0, Some(ALICE), Some(Permill::from_float(1.525)), @@ -1070,9 +1258,6 @@ fn add_resource_on_mint_works() { #[test] fn add_resource_on_mint_beyond_max_fails() { ExtBuilder::default().build().execute_with(|| { - let basic_resource: BasicResource> = - BasicResource { src: None, metadata: None, license: None, thumb: None }; - // Create a basic collection assert_ok!(basic_collection()); @@ -1088,16 +1273,16 @@ fn add_resource_on_mint_beyond_max_fails() { ]; // Mint NFT - RMRKCore::mint_nft( + assert_ok!(RMRKCore::mint_nft( Origin::signed(ALICE), - None, + Some(ALICE), COLLECTION_ID_0, Some(ALICE), Some(Permill::from_float(1.525)), bvec![0u8; 20], true, Some(resources_to_add), - ) + )); }); } diff --git a/pallets/rmrk-equip/src/tests.rs b/pallets/rmrk-equip/src/tests.rs index 7db33737..149cfdcc 100644 --- a/pallets/rmrk-equip/src/tests.rs +++ b/pallets/rmrk-equip/src/tests.rs @@ -174,7 +174,7 @@ fn equip_works() { // Mint NFT 0 from collection 0 (character-0) assert_ok!(RmrkCore::mint_nft( Origin::signed(ALICE), - None, // owner + Some(ALICE), // owner 0, // collection ID Some(ALICE), // recipient Some(Permill::from_float(1.525)), // royalties @@ -186,7 +186,7 @@ fn equip_works() { // Mint NFT 1 from collection 0 (character-1) assert_ok!(RmrkCore::mint_nft( Origin::signed(ALICE), - None, // owner + Some(ALICE), // owner 0, // collection ID Some(ALICE), // recipient Some(Permill::from_float(1.525)), // royalties @@ -198,7 +198,7 @@ fn equip_works() { // Mint NFT 0 from collection 1 (sword) assert_ok!(RmrkCore::mint_nft( Origin::signed(ALICE), - None, // owner + Some(ALICE), // owner 1, // collection ID Some(ALICE), // recipient Some(Permill::from_float(1.525)), // royalties @@ -210,7 +210,7 @@ fn equip_works() { // Mint NFT 1 from collection 1 (flashlight) assert_ok!(RmrkCore::mint_nft( Origin::signed(ALICE), - None, // owner + Some(ALICE), // owner 1, // collection ID Some(ALICE), // recipient Some(Permill::from_float(1.525)), // royalties diff --git a/pallets/rmrk-market/src/tests.rs b/pallets/rmrk-market/src/tests.rs index 2ed12b23..4f79ad95 100644 --- a/pallets/rmrk-market/src/tests.rs +++ b/pallets/rmrk-market/src/tests.rs @@ -40,7 +40,7 @@ fn basic_collection() -> DispatchResult { fn basic_mint() -> DispatchResult { RmrkCore::mint_nft( Origin::signed(ALICE), - None, + Some(ALICE), COLLECTION_ID_0, Some(ALICE), Some(Permill::from_float(1.525)), diff --git a/traits/src/nft.rs b/traits/src/nft.rs index 739b9dc7..2ef1074a 100644 --- a/traits/src/nft.rs +++ b/traits/src/nft.rs @@ -64,6 +64,15 @@ pub trait Nft { metadata: BoundedString, transferable: bool, ) -> Result<(CollectionId, NftId), DispatchError>; + fn nft_mint_directly_to_nft( + sender: AccountId, + owner: (CollectionId, NftId), + collection_id: CollectionId, + royalty_recipient: Option, + royalty_amount: Option, + metadata: BoundedString, + transferable: bool, + ) -> Result<(CollectionId, NftId), DispatchError>; fn nft_burn( collection_id: CollectionId, nft_id: NftId, diff --git a/traits/src/resource.rs b/traits/src/resource.rs index 477541a0..2991291f 100644 --- a/traits/src/resource.rs +++ b/traits/src/resource.rs @@ -138,6 +138,7 @@ pub trait Resource { collection_id: CollectionId, nft_id: NftId, resource: ResourceTypes, + adding_on_mint: bool, ) -> Result; fn accept( sender: AccountId,