From de074fa9bf4c961198ee93f8a1bcffabf3224f5e Mon Sep 17 00:00:00 2001 From: Jegor Sidorenko Date: Thu, 1 Sep 2022 12:41:20 +0300 Subject: [PATCH 1/4] Allow to add tips when buying an NFT --- frame/nfts/src/functions.rs | 12 +++++++ frame/nfts/src/lib.rs | 11 +++++- frame/nfts/src/tests.rs | 70 +++++++++++++++++++++++++++++++++---- frame/nfts/src/types.rs | 1 + 4 files changed, 87 insertions(+), 7 deletions(-) diff --git a/frame/nfts/src/functions.rs b/frame/nfts/src/functions.rs index 107214558307f..26f3fbe6d0264 100644 --- a/frame/nfts/src/functions.rs +++ b/frame/nfts/src/functions.rs @@ -239,6 +239,7 @@ impl, I: 'static> Pallet { item: T::ItemId, buyer: T::AccountId, bid_price: ItemPrice, + tips: Vec>, ) -> DispatchResult { let details = Item::::get(&collection, &item).ok_or(Error::::UnknownItem)?; ensure!(details.owner != buyer, Error::::NoPermission); @@ -259,6 +260,17 @@ impl, I: 'static> Pallet { ExistenceRequirement::KeepAlive, )?; + for tip in tips { + T::Currency::transfer(&buyer, &tip.0, tip.1, ExistenceRequirement::KeepAlive)?; + Self::deposit_event(Event::TipSent { + collection, + item, + sender: buyer.clone(), + receiver: tip.0, + amount: tip.1, + }); + } + let old_owner = details.owner.clone(); Self::do_transfer(collection, item, buyer.clone(), |_, _| Ok(()))?; diff --git a/frame/nfts/src/lib.rs b/frame/nfts/src/lib.rs index cb96e8138ba5e..ef9e4973ecd58 100644 --- a/frame/nfts/src/lib.rs +++ b/frame/nfts/src/lib.rs @@ -375,6 +375,14 @@ pub mod pallet { seller: T::AccountId, buyer: T::AccountId, }, + /// A tip was sent. + TipSent { + collection: T::CollectionId, + item: T::ItemId, + sender: T::AccountId, + receiver: T::AccountId, + amount: DepositBalanceOf, + }, } #[pallet::error] @@ -1489,9 +1497,10 @@ pub mod pallet { collection: T::CollectionId, item: T::ItemId, bid_price: ItemPrice, + tips: Vec>, ) -> DispatchResult { let origin = ensure_signed(origin)?; - Self::do_buy_item(collection, item, origin, bid_price) + Self::do_buy_item(collection, item, origin, bid_price, tips) } } } diff --git a/frame/nfts/src/tests.rs b/frame/nfts/src/tests.rs index 2b20d124bd9ae..88d68d5d6d34b 100644 --- a/frame/nfts/src/tests.rs +++ b/frame/nfts/src/tests.rs @@ -766,12 +766,18 @@ fn buy_item_should_work() { // can't buy for less assert_noop!( - Nfts::buy_item(Origin::signed(user_2), collection_id, item_1, 1), + Nfts::buy_item(Origin::signed(user_2), collection_id, item_1, 1, vec![]), Error::::BidTooLow ); // pass the higher price to validate it will still deduct correctly - assert_ok!(Nfts::buy_item(Origin::signed(user_2), collection_id, item_1, price_1 + 1,)); + assert_ok!(Nfts::buy_item( + Origin::signed(user_2), + collection_id, + item_1, + price_1 + 1, + vec![] + )); // validate the new owner & balances let item = Item::::get(collection_id, item_1).unwrap(); @@ -781,18 +787,18 @@ fn buy_item_should_work() { // can't buy from yourself assert_noop!( - Nfts::buy_item(Origin::signed(user_1), collection_id, item_2, price_2), + Nfts::buy_item(Origin::signed(user_1), collection_id, item_2, price_2, vec![]), Error::::NoPermission ); // can't buy when the item is listed for a specific buyer assert_noop!( - Nfts::buy_item(Origin::signed(user_2), collection_id, item_2, price_2), + Nfts::buy_item(Origin::signed(user_2), collection_id, item_2, price_2, vec![]), Error::::NoPermission ); // can buy when I'm a whitelisted buyer - assert_ok!(Nfts::buy_item(Origin::signed(user_3), collection_id, item_2, price_2,)); + assert_ok!(Nfts::buy_item(Origin::signed(user_3), collection_id, item_2, price_2, vec![])); assert!(events().contains(&Event::::ItemBought { collection: collection_id, @@ -807,7 +813,7 @@ fn buy_item_should_work() { // can't buy when item is not for sale assert_noop!( - Nfts::buy_item(Origin::signed(user_2), collection_id, item_3, price_2), + Nfts::buy_item(Origin::signed(user_2), collection_id, item_3, price_2, vec![]), Error::::NotForSale ); @@ -828,6 +834,7 @@ fn buy_item_should_work() { collection: collection_id, item: item_3, bid_price: price_1, + tips: vec![], }); assert_noop!(buy_item_call.dispatch(Origin::signed(user_2)), Error::::Frozen); @@ -840,8 +847,59 @@ fn buy_item_should_work() { collection: collection_id, item: item_3, bid_price: price_1, + tips: vec![], }); assert_noop!(buy_item_call.dispatch(Origin::signed(user_2)), Error::::Frozen); } }); } + +#[test] +fn buy_item_with_tips_should_work() { + new_test_ext().execute_with(|| { + let user_1 = 1; + let user_2 = 2; + let user_3 = 3; + let collection_id = 0; + let item_id = 1; + let price = 20; + let tip = 2; + let initial_balance = 100; + + Balances::make_free_balance_be(&user_1, initial_balance); + Balances::make_free_balance_be(&user_2, initial_balance); + Balances::make_free_balance_be(&user_3, initial_balance); + + assert_ok!(Nfts::force_create(Origin::root(), collection_id, user_1, true)); + assert_ok!(Nfts::mint(Origin::signed(user_1), collection_id, item_id, user_1)); + + assert_ok!(Nfts::set_price( + Origin::signed(user_1), + collection_id, + item_id, + Some(price), + None, + )); + + assert_ok!(Nfts::buy_item( + Origin::signed(user_2), + collection_id, + item_id, + price, + vec![(user_3, tip)], + )); + + // validate balances + assert_eq!(Balances::total_balance(&user_1), initial_balance + price); + assert_eq!(Balances::total_balance(&user_2), initial_balance - price - tip); + assert_eq!(Balances::total_balance(&user_3), initial_balance + tip); + + assert!(events().contains(&Event::::TipSent { + collection: collection_id, + item: item_id, + sender: user_2, + receiver: user_3, + amount: tip, + })); + }); +} diff --git a/frame/nfts/src/types.rs b/frame/nfts/src/types.rs index 1081ec8110288..08fbf73031cdf 100644 --- a/frame/nfts/src/types.rs +++ b/frame/nfts/src/types.rs @@ -32,6 +32,7 @@ pub(super) type ItemDetailsFor = ItemDetails<::AccountId, DepositBalanceOf>; pub(super) type ItemPrice = <>::Currency as Currency<::AccountId>>::Balance; +pub(super) type Tip = (::AccountId, DepositBalanceOf); #[derive(Clone, Encode, Decode, Eq, PartialEq, RuntimeDebug, TypeInfo, MaxEncodedLen)] pub struct CollectionDetails { From 1f525c805739528269470dac7ffba3b147975b93 Mon Sep 17 00:00:00 2001 From: Jegor Sidorenko Date: Fri, 2 Sep 2022 12:34:19 +0300 Subject: [PATCH 2/4] Chore --- frame/nfts/src/types.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frame/nfts/src/types.rs b/frame/nfts/src/types.rs index 08fbf73031cdf..0b40e46abcda2 100644 --- a/frame/nfts/src/types.rs +++ b/frame/nfts/src/types.rs @@ -30,9 +30,10 @@ pub(super) type CollectionDetailsFor = CollectionDetails<::AccountId, DepositBalanceOf>; pub(super) type ItemDetailsFor = ItemDetails<::AccountId, DepositBalanceOf>; -pub(super) type ItemPrice = +pub(super) type BalanceOf = <>::Currency as Currency<::AccountId>>::Balance; -pub(super) type Tip = (::AccountId, DepositBalanceOf); +pub(super) type ItemPrice = BalanceOf; +pub(super) type Tip = (::AccountId, BalanceOf); #[derive(Clone, Encode, Decode, Eq, PartialEq, RuntimeDebug, TypeInfo, MaxEncodedLen)] pub struct CollectionDetails { From 8aaab6ceb822dafb4076392319d8b3ca40d03fe9 Mon Sep 17 00:00:00 2001 From: Jegor Sidorenko Date: Mon, 5 Sep 2022 17:40:02 +0300 Subject: [PATCH 3/4] Rework tips feature --- frame/nfts/src/functions.rs | 12 ------ frame/nfts/src/lib.rs | 18 ++++++++- frame/nfts/src/tests.rs | 48 ++++++------------------ frame/nfts/src/types.rs | 7 +++- frame/nfts/src/user_features/buy_sell.rs | 38 +++++++++++++++++++ frame/nfts/src/user_features/mod.rs | 18 +++++++++ 6 files changed, 90 insertions(+), 51 deletions(-) create mode 100644 frame/nfts/src/user_features/buy_sell.rs create mode 100644 frame/nfts/src/user_features/mod.rs diff --git a/frame/nfts/src/functions.rs b/frame/nfts/src/functions.rs index 26f3fbe6d0264..107214558307f 100644 --- a/frame/nfts/src/functions.rs +++ b/frame/nfts/src/functions.rs @@ -239,7 +239,6 @@ impl, I: 'static> Pallet { item: T::ItemId, buyer: T::AccountId, bid_price: ItemPrice, - tips: Vec>, ) -> DispatchResult { let details = Item::::get(&collection, &item).ok_or(Error::::UnknownItem)?; ensure!(details.owner != buyer, Error::::NoPermission); @@ -260,17 +259,6 @@ impl, I: 'static> Pallet { ExistenceRequirement::KeepAlive, )?; - for tip in tips { - T::Currency::transfer(&buyer, &tip.0, tip.1, ExistenceRequirement::KeepAlive)?; - Self::deposit_event(Event::TipSent { - collection, - item, - sender: buyer.clone(), - receiver: tip.0, - amount: tip.1, - }); - } - let old_owner = details.owner.clone(); Self::do_transfer(collection, item, buyer.clone(), |_, _| Ok(()))?; diff --git a/frame/nfts/src/lib.rs b/frame/nfts/src/lib.rs index ef9e4973ecd58..d552a271c032a 100644 --- a/frame/nfts/src/lib.rs +++ b/frame/nfts/src/lib.rs @@ -38,6 +38,7 @@ mod tests; mod functions; mod impl_nonfungibles; mod types; +mod user_features; pub mod weights; @@ -1497,10 +1498,23 @@ pub mod pallet { collection: T::CollectionId, item: T::ItemId, bid_price: ItemPrice, - tips: Vec>, ) -> DispatchResult { let origin = ensure_signed(origin)?; - Self::do_buy_item(collection, item, origin, bid_price, tips) + Self::do_buy_item(collection, item, origin, bid_price) + } + + /// Allows to pay the tips. + /// + /// Origin must be Signed. + /// + /// - `tips`: Tips array. + /// + /// Emits `TipSent` on every tip transfer. + #[pallet::weight(0)] + #[transactional] + pub fn pay_tips(origin: OriginFor, tips: Vec>) -> DispatchResult { + let origin = ensure_signed(origin)?; + Self::do_pay_tips(origin, tips) } } } diff --git a/frame/nfts/src/tests.rs b/frame/nfts/src/tests.rs index 88d68d5d6d34b..2bd7129d97b38 100644 --- a/frame/nfts/src/tests.rs +++ b/frame/nfts/src/tests.rs @@ -766,18 +766,12 @@ fn buy_item_should_work() { // can't buy for less assert_noop!( - Nfts::buy_item(Origin::signed(user_2), collection_id, item_1, 1, vec![]), + Nfts::buy_item(Origin::signed(user_2), collection_id, item_1, 1), Error::::BidTooLow ); // pass the higher price to validate it will still deduct correctly - assert_ok!(Nfts::buy_item( - Origin::signed(user_2), - collection_id, - item_1, - price_1 + 1, - vec![] - )); + assert_ok!(Nfts::buy_item(Origin::signed(user_2), collection_id, item_1, price_1 + 1)); // validate the new owner & balances let item = Item::::get(collection_id, item_1).unwrap(); @@ -787,18 +781,18 @@ fn buy_item_should_work() { // can't buy from yourself assert_noop!( - Nfts::buy_item(Origin::signed(user_1), collection_id, item_2, price_2, vec![]), + Nfts::buy_item(Origin::signed(user_1), collection_id, item_2, price_2), Error::::NoPermission ); // can't buy when the item is listed for a specific buyer assert_noop!( - Nfts::buy_item(Origin::signed(user_2), collection_id, item_2, price_2, vec![]), + Nfts::buy_item(Origin::signed(user_2), collection_id, item_2, price_2), Error::::NoPermission ); // can buy when I'm a whitelisted buyer - assert_ok!(Nfts::buy_item(Origin::signed(user_3), collection_id, item_2, price_2, vec![])); + assert_ok!(Nfts::buy_item(Origin::signed(user_3), collection_id, item_2, price_2)); assert!(events().contains(&Event::::ItemBought { collection: collection_id, @@ -813,7 +807,7 @@ fn buy_item_should_work() { // can't buy when item is not for sale assert_noop!( - Nfts::buy_item(Origin::signed(user_2), collection_id, item_3, price_2, vec![]), + Nfts::buy_item(Origin::signed(user_2), collection_id, item_3, price_2), Error::::NotForSale ); @@ -834,7 +828,6 @@ fn buy_item_should_work() { collection: collection_id, item: item_3, bid_price: price_1, - tips: vec![], }); assert_noop!(buy_item_call.dispatch(Origin::signed(user_2)), Error::::Frozen); @@ -847,7 +840,6 @@ fn buy_item_should_work() { collection: collection_id, item: item_3, bid_price: price_1, - tips: vec![], }); assert_noop!(buy_item_call.dispatch(Origin::signed(user_2)), Error::::Frozen); } @@ -855,14 +847,13 @@ fn buy_item_should_work() { } #[test] -fn buy_item_with_tips_should_work() { +fn pay_tips_should_work() { new_test_ext().execute_with(|| { let user_1 = 1; let user_2 = 2; let user_3 = 3; let collection_id = 0; let item_id = 1; - let price = 20; let tip = 2; let initial_balance = 100; @@ -870,34 +861,19 @@ fn buy_item_with_tips_should_work() { Balances::make_free_balance_be(&user_2, initial_balance); Balances::make_free_balance_be(&user_3, initial_balance); - assert_ok!(Nfts::force_create(Origin::root(), collection_id, user_1, true)); - assert_ok!(Nfts::mint(Origin::signed(user_1), collection_id, item_id, user_1)); - - assert_ok!(Nfts::set_price( + assert_ok!(Nfts::pay_tips( Origin::signed(user_1), - collection_id, - item_id, - Some(price), - None, - )); - - assert_ok!(Nfts::buy_item( - Origin::signed(user_2), - collection_id, - item_id, - price, - vec![(user_3, tip)], + vec![(collection_id, item_id, user_2, tip), (collection_id, item_id, user_3, tip)] )); - // validate balances - assert_eq!(Balances::total_balance(&user_1), initial_balance + price); - assert_eq!(Balances::total_balance(&user_2), initial_balance - price - tip); + assert_eq!(Balances::total_balance(&user_1), initial_balance - tip * 2); + assert_eq!(Balances::total_balance(&user_2), initial_balance + tip); assert_eq!(Balances::total_balance(&user_3), initial_balance + tip); assert!(events().contains(&Event::::TipSent { collection: collection_id, item: item_id, - sender: user_2, + sender: user_1, receiver: user_3, amount: tip, })); diff --git a/frame/nfts/src/types.rs b/frame/nfts/src/types.rs index 0b40e46abcda2..198e45b4c3c66 100644 --- a/frame/nfts/src/types.rs +++ b/frame/nfts/src/types.rs @@ -33,7 +33,12 @@ pub(super) type ItemDetailsFor = pub(super) type BalanceOf = <>::Currency as Currency<::AccountId>>::Balance; pub(super) type ItemPrice = BalanceOf; -pub(super) type Tip = (::AccountId, BalanceOf); +pub(super) type ItemTip = ( + >::CollectionId, + >::ItemId, + ::AccountId, + BalanceOf, +); #[derive(Clone, Encode, Decode, Eq, PartialEq, RuntimeDebug, TypeInfo, MaxEncodedLen)] pub struct CollectionDetails { diff --git a/frame/nfts/src/user_features/buy_sell.rs b/frame/nfts/src/user_features/buy_sell.rs new file mode 100644 index 0000000000000..dfd533faeea37 --- /dev/null +++ b/frame/nfts/src/user_features/buy_sell.rs @@ -0,0 +1,38 @@ +// This file is part of Substrate. + +// Copyright (C) 2022 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::*; +use frame_support::{ + pallet_prelude::*, + traits::{Currency, ExistenceRequirement::KeepAlive}, +}; + +impl, I: 'static> Pallet { + pub fn do_pay_tips(sender: T::AccountId, tips: Vec>) -> DispatchResult { + for tip in tips { + T::Currency::transfer(&sender, &tip.2, tip.3, KeepAlive)?; + Self::deposit_event(Event::TipSent { + collection: tip.0, + item: tip.1, + sender: sender.clone(), + receiver: tip.2, + amount: tip.3, + }); + } + Ok(()) + } +} diff --git a/frame/nfts/src/user_features/mod.rs b/frame/nfts/src/user_features/mod.rs new file mode 100644 index 0000000000000..5661797978439 --- /dev/null +++ b/frame/nfts/src/user_features/mod.rs @@ -0,0 +1,18 @@ +// This file is part of Substrate. + +// Copyright (C) 2022 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +pub mod buy_sell; From b5378600c9ef752e6611541774939f9ed4e2bfbe Mon Sep 17 00:00:00 2001 From: Jegor Sidorenko Date: Mon, 5 Sep 2022 18:31:16 +0300 Subject: [PATCH 4/4] Add markeplace support --- bin/node/runtime/src/lib.rs | 5 + frame/nfts/src/functions.rs | 9 +- frame/nfts/src/lib.rs | 107 ++++++++++++++ frame/nfts/src/mock.rs | 1 + frame/nfts/src/tests.rs | 169 +++++++++++++++++++++++ frame/nfts/src/types.rs | 12 ++ frame/nfts/src/user_features/buy_sell.rs | 97 +++++++++++++ 7 files changed, 399 insertions(+), 1 deletion(-) diff --git a/bin/node/runtime/src/lib.rs b/bin/node/runtime/src/lib.rs index 28cc2452039ac..5b48b782744e1 100644 --- a/bin/node/runtime/src/lib.rs +++ b/bin/node/runtime/src/lib.rs @@ -1476,6 +1476,10 @@ impl pallet_uniques::Config for Runtime { type Locker = (); } +parameter_types! { + pub const SellerTipsLimit: u32 = 10; +} + impl pallet_nfts::Config for Runtime { type Event = Event; type CollectionId = u32; @@ -1495,6 +1499,7 @@ impl pallet_nfts::Config for Runtime { type Helper = (); type CreateOrigin = AsEnsureOriginWithArg>; type Locker = (); + type SellerTipsLimit = SellerTipsLimit; } impl pallet_transaction_storage::Config for Runtime { diff --git a/frame/nfts/src/functions.rs b/frame/nfts/src/functions.rs index 107214558307f..e205beb2c8a08 100644 --- a/frame/nfts/src/functions.rs +++ b/frame/nfts/src/functions.rs @@ -48,6 +48,12 @@ impl, I: 'static> Pallet { Account::::insert((&dest, &collection, &item), ()); let origin = details.owner; details.owner = dest; + + if let Some(ref seller) = details.seller.clone() { + details.seller = None; + Seller::::remove((seller, &collection, &item)); + } + Item::::insert(&collection, &item, &details); ItemPriceOf::::remove(&collection, &item); @@ -168,7 +174,8 @@ impl, I: 'static> Pallet { let owner = owner.clone(); Account::::insert((&owner, &collection, &item), ()); - let details = ItemDetails { owner, approved: None, is_frozen: false, deposit }; + let details = + ItemDetails { owner, approved: None, is_frozen: false, deposit, seller: None }; Item::::insert(&collection, &item, details); Ok(()) }, diff --git a/frame/nfts/src/lib.rs b/frame/nfts/src/lib.rs index d552a271c032a..786a9e072cdcb 100644 --- a/frame/nfts/src/lib.rs +++ b/frame/nfts/src/lib.rs @@ -156,6 +156,10 @@ pub mod pallet { /// Weight information for extrinsics in this pallet. type WeightInfo: WeightInfo; + + /// The maximum amount of seller tips records an item could have. + #[pallet::constant] + type SellerTipsLimit: Get; } #[pallet::storage] @@ -268,6 +272,19 @@ pub mod pallet { pub(super) type CollectionMaxSupply, I: 'static = ()> = StorageMap<_, Blake2_128Concat, T::CollectionId, u32, OptionQuery>; + #[pallet::storage] + /// Sellers and items they could sell. + pub(super) type Seller, I: 'static = ()> = StorageNMap< + _, + ( + NMapKey, // seller + NMapKey, + NMapKey, + ), + ItemSellData, ItemTip, T::SellerTipsLimit>, + OptionQuery, + >; + #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event, I: 'static = ()> { @@ -384,6 +401,16 @@ pub mod pallet { receiver: T::AccountId, amount: DepositBalanceOf, }, + /// A seller was set for an item. + SellerSet { + collection: T::CollectionId, + item: T::ItemId, + seller: T::AccountId, + price: ItemPrice, + tips: SellerTipsOf, + }, + /// A seller was removed from an item. + SellerRemoved { collection: T::CollectionId, item: T::ItemId, seller: T::AccountId }, } #[pallet::error] @@ -424,6 +451,12 @@ pub mod pallet { NotForSale, /// The provided bid is too low. BidTooLow, + /// Wrong seller provided. + WrongSeller, + /// Wrong price provided. + WrongPrice, + /// Wrong tips provided. + WrongTips, } impl, I: 'static> Pallet { @@ -1516,5 +1549,79 @@ pub mod pallet { let origin = ensure_signed(origin)?; Self::do_pay_tips(origin, tips) } + + /// Allows to set the seller for an item and to set the tips those should be paid. + /// + /// Origin must be Signed and must not be the owner of the `item`. + /// + /// - `collection`: The collection of the item. + /// - `item`: The item the owner wants to set the seller for. + /// - `seller`: Seller's account. + /// - `price`: The price for an item. + /// - `tips`: Tips those should be paid. + /// + /// Emits `SellerSet` on success. + #[pallet::weight(0)] + pub fn set_seller( + origin: OriginFor, + collection: T::CollectionId, + item: T::ItemId, + seller: AccountIdLookupOf, + price: ItemPrice, + tips: SellerTipsOf, + ) -> DispatchResult { + let origin = ensure_signed(origin)?; + let seller = T::Lookup::lookup(seller)?; + Self::do_set_seller(collection, item, origin, seller, price, tips) + } + + /// Removes the seller from an item. + /// + /// Origin must be Signed and must not be the owner of the `item`. + /// + /// - `collection`: The collection of the item. + /// - `item`: The item the owner wants to remove the seller from. + /// - `seller`: Previously set seller's account. + /// + /// Emits `SellerRemoved` on success. + #[pallet::weight(0)] + pub fn remove_seller( + origin: OriginFor, + collection: T::CollectionId, + item: T::ItemId, + seller: AccountIdLookupOf, + ) -> DispatchResult { + let origin = ensure_signed(origin)?; + let seller = T::Lookup::lookup(seller)?; + Self::do_remove_seller(collection, item, origin, seller) + } + + /// Allows to buy an item from the seller. + /// + /// Origin must be Signed and must not be the owner of the `item`. + /// + /// Note: the `price` and `tips` params need to be provided to ensure it won't be + /// possible to change them in the state by front-running that call. + /// + /// - `collection`: The collection of the item. + /// - `item`: The item the sender wants to buy. + /// - `seller`: Previously set seller's account. + /// - `price`: Previously set price. + /// - `tips`: Previously set tips. + /// + /// Emits `ItemBought` on success. + #[pallet::weight(0)] + pub fn buy_from_seller( + origin: OriginFor, + collection: T::CollectionId, + item: T::ItemId, + seller: AccountIdLookupOf, + price: ItemPrice, + tips: SellerTipsOf, + ) -> DispatchResult { + let origin = ensure_signed(origin)?; + let seller = T::Lookup::lookup(seller)?; + Self::do_buy_from_seller(collection, item, origin, seller, price, tips) + } } } diff --git a/frame/nfts/src/mock.rs b/frame/nfts/src/mock.rs index f3040faac5f40..a668879d33569 100644 --- a/frame/nfts/src/mock.rs +++ b/frame/nfts/src/mock.rs @@ -103,6 +103,7 @@ impl Config for Test { type WeightInfo = (); #[cfg(feature = "runtime-benchmarks")] type Helper = (); + type SellerTipsLimit = ConstU32<10>; } pub(crate) fn new_test_ext() -> sp_io::TestExternalities { diff --git a/frame/nfts/src/tests.rs b/frame/nfts/src/tests.rs index 2bd7129d97b38..d57db2d4eed69 100644 --- a/frame/nfts/src/tests.rs +++ b/frame/nfts/src/tests.rs @@ -879,3 +879,172 @@ fn pay_tips_should_work() { })); }); } + +#[test] +fn set_remove_seller_should_work() { + new_test_ext().execute_with(|| { + let user_id = 1; + let seller_id = 2; + let collection_id = 0; + let item_id = 1; + let price = 10; + let tips: SellerTipsOf = bvec![(collection_id, item_id, user_id, 1)]; + + assert_ok!(Nfts::force_create(Origin::root(), collection_id, user_id, true)); + assert_ok!(Nfts::mint(Origin::signed(user_id), collection_id, item_id, user_id)); + + assert_ok!(Nfts::set_seller( + Origin::signed(user_id), + collection_id, + item_id, + seller_id, + price, + tips.clone(), + )); + + let item = Item::::get(collection_id, item_id).unwrap(); + assert_eq!(item.seller, Some(seller_id)); + + let sell_data = Seller::::get((seller_id, collection_id, item_id)).unwrap(); + assert_eq!(sell_data.price, price); + assert_eq!(sell_data.tips, tips); + + assert!(events().contains(&Event::::SellerSet { + collection: collection_id, + item: item_id, + seller: seller_id, + price, + tips, + })); + + // reset the seller by setting the seller field to `None` + assert_ok!( + Nfts::remove_seller(Origin::signed(user_id), collection_id, item_id, seller_id,) + ); + let item = Item::::get(collection_id, item_id).unwrap(); + assert_eq!(item.seller, None); + assert!(!Seller::::contains_key((seller_id, collection_id, item_id))); + + assert!(events().contains(&Event::::SellerRemoved { + collection: collection_id, + item: item_id, + seller: seller_id, + })); + }); +} + +#[test] +fn buy_from_seller_should_work() { + new_test_ext().execute_with(|| { + let user_1 = 1; + let user_2 = 2; + let seller_id = 4; + let collection_id = 0; + let item_id = 1; + let price = 20; + let seller_tips = 10; + let tips: SellerTipsOf = bvec![(collection_id, item_id, seller_id, seller_tips)]; + let initial_balance = 100; + let total = price + seller_tips; + + Balances::make_free_balance_be(&user_1, initial_balance); + Balances::make_free_balance_be(&user_2, initial_balance); + Balances::make_free_balance_be(&seller_id, initial_balance); + + assert_ok!(Nfts::force_create(Origin::root(), collection_id, user_1, true)); + assert_ok!(Nfts::mint(Origin::signed(user_1), collection_id, item_id, user_1)); + + // can't by if seller is not set + assert_noop!( + Nfts::buy_from_seller( + Origin::signed(user_2), + collection_id, + item_id, + seller_id, + price, + tips.clone(), + ), + Error::::NotForSale + ); + + // set the seller + assert_ok!(Nfts::set_seller( + Origin::signed(user_1), + collection_id, + item_id, + seller_id, + price, + tips.clone(), + )); + + // can't buy if the price differs + assert_noop!( + Nfts::buy_from_seller( + Origin::signed(user_2), + collection_id, + item_id, + seller_id, + price - 1, + tips.clone(), + ), + Error::::WrongPrice + ); + + // can't buy from yourself + assert_noop!( + Nfts::buy_from_seller( + Origin::signed(user_1), + collection_id, + item_id, + seller_id, + price, + tips.clone(), + ), + Error::::NoPermission + ); + + // can't buy when the provided seller is wrong + assert_noop!( + Nfts::buy_from_seller( + Origin::signed(user_2), + collection_id, + item_id, + user_1, + price, + tips.clone(), + ), + Error::::WrongSeller + ); + + // can buy with the right params + assert_ok!(Nfts::buy_from_seller( + Origin::signed(user_2), + collection_id, + item_id, + seller_id, + price, + tips.clone(), + )); + + // validate the new owner + let item = Item::::get(collection_id, item_id).unwrap(); + assert_eq!(item.owner, user_2); + + // validate balances + assert_eq!(Balances::total_balance(&user_1), initial_balance + price); + assert_eq!(Balances::total_balance(&user_2), initial_balance - total); + assert_eq!(Balances::total_balance(&seller_id), initial_balance + seller_tips); + + assert!(events().contains(&Event::::ItemBought { + collection: collection_id, + item: item_id, + price, + seller: user_1, + buyer: user_2, + })); + + // ensure we reset the seller field + assert_eq!(Item::::get(collection_id, item_id).unwrap().seller, None); + assert!(!Seller::::contains_key((seller_id, collection_id, item_id))); + }); +} diff --git a/frame/nfts/src/types.rs b/frame/nfts/src/types.rs index 198e45b4c3c66..93edccb790e87 100644 --- a/frame/nfts/src/types.rs +++ b/frame/nfts/src/types.rs @@ -39,6 +39,8 @@ pub(super) type ItemTip = ( ::AccountId, BalanceOf, ); +pub(super) type SellerTipsOf = + BoundedVec, >::SellerTipsLimit>; #[derive(Clone, Encode, Decode, Eq, PartialEq, RuntimeDebug, TypeInfo, MaxEncodedLen)] pub struct CollectionDetails { @@ -101,6 +103,8 @@ pub struct ItemDetails { /// The amount held in the pallet's default account for this item. Free-hold items will have /// this as zero. pub(super) deposit: DepositBalance, + /// An approved seller of this item, if one is set. + pub(super) seller: Option, } #[derive(Clone, Encode, Decode, Eq, PartialEq, RuntimeDebug, Default, TypeInfo, MaxEncodedLen)] @@ -134,3 +138,11 @@ pub struct ItemMetadata> { /// Whether the item metadata may be changed by a non Force origin. pub(super) is_frozen: bool, } + +#[derive(Clone, Encode, Decode, Eq, PartialEq, Default, MaxEncodedLen, TypeInfo)] +#[scale_info(skip_type_params(TipsLimit))] +#[codec(mel_bound(ItemPrice: MaxEncodedLen, Tip: MaxEncodedLen))] +pub struct ItemSellData> { + pub price: ItemPrice, + pub tips: BoundedVec, +} diff --git a/frame/nfts/src/user_features/buy_sell.rs b/frame/nfts/src/user_features/buy_sell.rs index dfd533faeea37..641795c4eba1b 100644 --- a/frame/nfts/src/user_features/buy_sell.rs +++ b/frame/nfts/src/user_features/buy_sell.rs @@ -35,4 +35,101 @@ impl, I: 'static> Pallet { } Ok(()) } + + pub fn do_set_seller( + collection_id: T::CollectionId, + item_id: T::ItemId, + sender: T::AccountId, + seller: T::AccountId, + price: ItemPrice, + tips: SellerTipsOf, + ) -> DispatchResult { + Item::::try_mutate(&collection_id, &item_id, |maybe_item| { + let item = maybe_item.as_mut().ok_or(Error::::UnknownItem)?; + ensure!(item.owner == sender, Error::::NoPermission); + + item.seller = Some(seller.clone()); + + Seller::::insert( + (&seller, &collection_id, &item_id), + ItemSellData { price, tips: tips.clone() }, + ); + + Self::deposit_event(Event::SellerSet { + collection: collection_id, + item: item_id, + seller, + price, + tips, + }); + + Ok(()) + }) + } + + pub fn do_remove_seller( + collection_id: T::CollectionId, + item_id: T::ItemId, + sender: T::AccountId, + seller: T::AccountId, + ) -> DispatchResult { + Item::::try_mutate(&collection_id, &item_id, |maybe_item| { + let item = maybe_item.as_mut().ok_or(Error::::UnknownItem)?; + ensure!(item.owner == sender, Error::::NoPermission); + + let mut is_correct_seller = false; + if let Some(ref item_seller) = item.seller { + is_correct_seller = *item_seller == seller; + } + ensure!(is_correct_seller, Error::::WrongSeller); + + Seller::::remove((&seller, &collection_id, &item_id)); + item.seller = None; + + Self::deposit_event(Event::SellerRemoved { + collection: collection_id, + item: item_id, + seller, + }); + + Ok(()) + }) + } + + pub fn do_buy_from_seller( + collection_id: T::CollectionId, + item_id: T::ItemId, + buyer: T::AccountId, + w_seller: T::AccountId, + w_price: ItemPrice, + w_tips: SellerTipsOf, + ) -> DispatchResult { + let item = Item::::get(collection_id, item_id).ok_or(Error::::UnknownItem)?; + ensure!(item.owner != buyer, Error::::NoPermission); + + let seller = item.seller.ok_or(Error::::NotForSale)?; + ensure!(seller == w_seller, Error::::WrongSeller); + + let ItemSellData { price, tips } = Seller::::get((seller, collection_id, item_id)) + .ok_or(Error::::NotForSale)?; + + ensure!(w_price == price, Error::::WrongPrice); + ensure!(w_tips == tips, Error::::WrongTips); + + T::Currency::transfer(&buyer, &item.owner, price.clone(), KeepAlive)?; + Self::do_pay_tips(buyer.clone(), tips.into())?; + + let old_owner = item.owner.clone(); + Self::do_transfer(collection_id, item_id, buyer.clone(), |_, _| Ok(()))?; + + Self::deposit_event(Event::ItemBought { + collection: collection_id, + item: item_id, + price, + seller: old_owner, + buyer, + }); + + Ok(()) + } }