diff --git a/Cargo.dev.toml b/Cargo.dev.toml index f3faebb04..ff3675c6d 100644 --- a/Cargo.dev.toml +++ b/Cargo.dev.toml @@ -11,4 +11,5 @@ members = [ "utilities", "vesting", "rewards", + "nft", ] diff --git a/nft/Cargo.toml b/nft/Cargo.toml new file mode 100644 index 000000000..b6eb1f436 --- /dev/null +++ b/nft/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "orml-nft" +description = "Utility pallet to perform ROOT calls in a PoA network" +repository = "https://github.com/open-web3-stack/open-runtime-module-library/tree/master/nft" +license = "Apache-2.0" +version = "0.2.1-dev" +authors = ["Acala Developers"] +edition = "2018" + +[dependencies] +codec = { package = "parity-scale-codec", version = "1.3.0", default-features = false } +sp-std = { version = "2.0.0", default-features = false } +sp-runtime = { version = "2.0.0", default-features = false } + +frame-support = { version = "2.0.0", default-features = false } +frame-system = { version = "2.0.0", default-features = false } + +[dev-dependencies] +sp-io = { version = "2.0.0", default-features = false } +sp-core = { version = "2.0.0", default-features = false } + +[features] +default = ["std"] +std = [ + "codec/std", + "sp-std/std", + "sp-runtime/std", + "frame-support/std", + "frame-system/std", +] diff --git a/nft/README.md b/nft/README.md new file mode 100644 index 000000000..54fe25257 --- /dev/null +++ b/nft/README.md @@ -0,0 +1,11 @@ +# Non-fungible-token module + +### Overview + +Non-fungible-token module provides basic functions to create and manager NFT(non fungible token) such as `create_class`, `transfer`, `mint`, `burn`, `destroy_class`. + +- `create_class` create NFT(non fungible token) class +- `transfer` transfer NFT(non fungible token) to another account. +- `mint` mint NFT(non fungible token) +- `burn` burn NFT(non fungible token) +- `destroy_class` destroy NFT(non fungible token) class diff --git a/nft/src/lib.rs b/nft/src/lib.rs new file mode 100644 index 000000000..cd49832d8 --- /dev/null +++ b/nft/src/lib.rs @@ -0,0 +1,219 @@ +//! # Non Fungible Token +//! The module provides implementations for non-fungible-token. +//! +//! - [`Trait`](./trait.Trait.html) +//! - [`Call`](./enum.Call.html) +//! - [`Module`](./struct.Module.html) +//! +//! ## Overview +//! +//! This module provides basic functions to create and manager +//! NFT(non fungible token) such as `create_class`, `transfer`, `mint`, `burn`. + +//! ### Module Functions +//! +//! - `create_class` - Create NFT(non fungible token) class +//! - `transfer` - Transfer NFT(non fungible token) to another account. +//! - `mint` - Mint NFT(non fungible token) +//! - `burn` - Burn NFT(non fungible token) +//! - `destroy_class` - Destroy NFT(non fungible token) class + +#![cfg_attr(not(feature = "std"), no_std)] + +use codec::{Decode, Encode}; +use frame_support::{decl_error, decl_module, decl_storage, ensure, Parameter}; +use sp_runtime::{ + traits::{AtLeast32BitUnsigned, CheckedAdd, CheckedSub, Member, One, Zero}, + DispatchError, DispatchResult, RuntimeDebug, +}; +use sp_std::vec::Vec; + +mod mock; +mod tests; + +pub type CID = Vec; + +/// Class info +#[derive(Encode, Decode, Clone, Eq, PartialEq, RuntimeDebug)] +pub struct ClassInfo { + /// Class metadata + pub metadata: CID, + /// Total issuance for the class + pub total_issuance: TokenId, + /// Class owner + pub owner: AccountId, + /// Class Properties + pub data: Data, +} + +/// Token info +#[derive(Encode, Decode, Clone, Eq, PartialEq, RuntimeDebug)] +pub struct TokenInfo { + /// Token metadata + pub metadata: CID, + /// Token owner + pub owner: AccountId, + /// Token Properties + pub data: Data, +} + +pub trait Trait: frame_system::Trait { + /// The class ID type + type ClassId: Parameter + Member + AtLeast32BitUnsigned + Default + Copy; + /// The token ID type + type TokenId: Parameter + Member + AtLeast32BitUnsigned + Default + Copy; + /// The class properties type + type ClassData: Parameter + Member; + /// The token properties type + type TokenData: Parameter + Member; +} + +decl_error! { + /// Error for non-fungible-token module. + pub enum Error for Module { + /// No available class ID + NoAvailableClassId, + /// No available token ID + NoAvailableTokenId, + /// Token(ClassId, TokenId) not found + TokenNotFound, + /// Class not found + ClassNotFound, + /// The operator is not the owner of the token and has no permission + NoPermission, + /// Arithmetic calculation overflow + NumOverflow, + /// Can not destroy class + /// Total issuance is not 0 + CannotDestroyClass, + } +} + +pub type ClassInfoOf = + ClassInfo<::TokenId, ::AccountId, ::ClassData>; +pub type TokenInfoOf = TokenInfo<::AccountId, ::TokenData>; + +decl_storage! { + trait Store for Module as NonFungibleToken { + /// Next available class ID. + pub NextClassId get(fn next_class_id): T::ClassId; + /// Next available token ID. + pub NextTokenId get(fn next_token_id): T::TokenId; + /// Store class info. + /// + /// Returns `None` if class info not set or removed. + pub Classes get(fn classes): map hasher(twox_64_concat) T::ClassId => Option>; + /// Store token info. + /// + /// Returns `None` if token info not set or removed. + pub Tokens get(fn tokens): double_map hasher(twox_64_concat) T::ClassId, hasher(twox_64_concat) T::TokenId => Option>; + /// Token existence check by owner and class ID. + pub TokensByOwner get(fn tokens_by_owner): double_map hasher(twox_64_concat) T::AccountId, hasher(twox_64_concat) (T::ClassId, T::TokenId) => Option<()>; + } +} + +decl_module! { + pub struct Module for enum Call where origin: T::Origin { + } +} + +impl Module { + /// Create NFT(non fungible token) class + pub fn create_class(owner: &T::AccountId, metadata: CID, data: T::ClassData) -> Result { + let class_id = NextClassId::::try_mutate(|id| -> Result { + let current_id = *id; + *id = id.checked_add(&One::one()).ok_or(Error::::NoAvailableClassId)?; + Ok(current_id) + })?; + + let info = ClassInfo { + metadata, + total_issuance: Default::default(), + owner: owner.clone(), + data, + }; + Classes::::insert(class_id, info); + + Ok(class_id) + } + + /// Transfer NFT(non fungible token) from `from` account to `to` account + pub fn transfer(from: &T::AccountId, to: &T::AccountId, token: (T::ClassId, T::TokenId)) -> DispatchResult { + if from == to { + return Ok(()); + } + + TokensByOwner::::try_mutate_exists(from, token, |token_by_owner| -> DispatchResult { + ensure!(token_by_owner.take().is_some(), Error::::NoPermission); + TokensByOwner::::insert(to, token, ()); + + Tokens::::try_mutate_exists(token.0, token.1, |token_info| -> DispatchResult { + let mut info = token_info.as_mut().ok_or(Error::::TokenNotFound)?; + info.owner = to.clone(); + Ok(()) + }) + }) + } + + /// Mint NFT(non fungible token) to `owner` + pub fn mint( + owner: &T::AccountId, + class_id: T::ClassId, + metadata: CID, + data: T::TokenData, + ) -> Result { + NextTokenId::::try_mutate(|id| -> Result { + let token_id = *id; + *id = id.checked_add(&One::one()).ok_or(Error::::NoAvailableTokenId)?; + + Classes::::try_mutate(class_id, |class_info| -> DispatchResult { + let info = class_info.as_mut().ok_or(Error::::ClassNotFound)?; + info.total_issuance = info + .total_issuance + .checked_add(&One::one()) + .ok_or(Error::::NumOverflow)?; + Ok(()) + })?; + + let token_info = TokenInfo { + metadata, + owner: owner.clone(), + data, + }; + Tokens::::insert(class_id, token_id, token_info); + TokensByOwner::::insert(owner, (class_id, token_id), ()); + + Ok(token_id) + }) + } + + /// Burn NFT(non fungible token) from `owner` + pub fn burn(owner: &T::AccountId, token: (T::ClassId, T::TokenId)) -> DispatchResult { + Tokens::::try_mutate_exists(token.0, token.1, |token_info| -> DispatchResult { + ensure!(token_info.take().is_some(), Error::::TokenNotFound); + + TokensByOwner::::try_mutate_exists(owner, token, |info| -> DispatchResult { + ensure!(info.take().is_some(), Error::::NoPermission); + + Classes::::try_mutate(token.0, |class_info| -> DispatchResult { + let info = class_info.as_mut().ok_or(Error::::ClassNotFound)?; + info.total_issuance = info + .total_issuance + .checked_sub(&One::one()) + .ok_or(Error::::NumOverflow)?; + Ok(()) + }) + }) + }) + } + + /// Destroy NFT(non fungible token) class + pub fn destroy_class(owner: &T::AccountId, class_id: T::ClassId) -> DispatchResult { + Classes::::try_mutate_exists(class_id, |class_info| -> DispatchResult { + let info = class_info.take().ok_or(Error::::ClassNotFound)?; + ensure!(info.owner == *owner, Error::::NoPermission); + ensure!(info.total_issuance == Zero::zero(), Error::::CannotDestroyClass); + Ok(()) + }) + } +} diff --git a/nft/src/mock.rs b/nft/src/mock.rs new file mode 100644 index 000000000..851453ae3 --- /dev/null +++ b/nft/src/mock.rs @@ -0,0 +1,90 @@ +//! Mocks for the gradually-update module. + +#![cfg(test)] + +use frame_support::{impl_outer_origin, parameter_types}; +use sp_core::H256; +use sp_runtime::{testing::Header, traits::IdentityLookup, Perbill}; + +use super::*; + +impl_outer_origin! { + pub enum Origin for Runtime {} +} + +// Workaround for https://github.com/rust-lang/rust/issues/26925 . Remove when sorted. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Runtime; +parameter_types! { + pub const BlockHashCount: u64 = 250; + pub const MaximumBlockWeight: u32 = 1024; + pub const MaximumBlockLength: u32 = 2 * 1024; + pub const AvailableBlockRatio: Perbill = Perbill::one(); +} + +pub type AccountId = u128; +pub type BlockNumber = u64; + +impl frame_system::Trait for Runtime { + type Origin = Origin; + type Index = u64; + type BlockNumber = BlockNumber; + type Call = (); + type Hash = H256; + type Hashing = ::sp_runtime::traits::BlakeTwo256; + type AccountId = AccountId; + type Lookup = IdentityLookup; + type Header = Header; + type Event = (); + type BlockHashCount = BlockHashCount; + type MaximumBlockWeight = MaximumBlockWeight; + type MaximumBlockLength = MaximumBlockLength; + type AvailableBlockRatio = AvailableBlockRatio; + type Version = (); + type PalletInfo = (); + type AccountData = (); + type OnNewAccount = (); + type OnKilledAccount = (); + type DbWeight = (); + type BlockExecutionWeight = (); + type ExtrinsicBaseWeight = (); + type MaximumExtrinsicWeight = (); + type BaseCallFilter = (); + type SystemWeightInfo = (); +} +pub type System = frame_system::Module; + +impl Trait for Runtime { + type ClassId = u64; + type TokenId = u64; + type ClassData = (); + type TokenData = (); +} +pub type NonFungibleTokenModule = Module; + +pub const ALICE: AccountId = 1; +pub const BOB: AccountId = 2; +pub const CLASS_ID: ::ClassId = 0; +pub const CLASS_ID_NOT_EXIST: ::ClassId = 1; +pub const TOKEN_ID: ::TokenId = 0; +pub const TOKEN_ID_NOT_EXIST: ::TokenId = 1; + +pub struct ExtBuilder; + +impl Default for ExtBuilder { + fn default() -> Self { + ExtBuilder + } +} + +impl ExtBuilder { + pub fn build(self) -> sp_io::TestExternalities { + let t = frame_system::GenesisConfig::default() + .build_storage::() + .unwrap(); + + let mut ext = sp_io::TestExternalities::new(t); + ext.execute_with(|| System::set_block_number(1)); + ext + } +} diff --git a/nft/src/tests.rs b/nft/src/tests.rs new file mode 100644 index 000000000..81187b78b --- /dev/null +++ b/nft/src/tests.rs @@ -0,0 +1,161 @@ +//! Unit tests for the non-fungible-token module. + +#![cfg(test)] + +use super::*; +use frame_support::{assert_noop, assert_ok}; +use mock::{ + ExtBuilder, NonFungibleTokenModule, Runtime, ALICE, BOB, CLASS_ID, CLASS_ID_NOT_EXIST, TOKEN_ID, TOKEN_ID_NOT_EXIST, +}; + +#[test] +fn create_class_should_work() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(NonFungibleTokenModule::create_class(&ALICE, vec![1], ())); + }); +} + +#[test] +fn create_class_should_fail() { + ExtBuilder::default().build().execute_with(|| { + NextClassId::::mutate(|id| *id = ::ClassId::max_value()); + assert_noop!( + NonFungibleTokenModule::create_class(&ALICE, vec![1], ()), + Error::::NoAvailableClassId + ); + }); +} + +#[test] +fn mint_should_work() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(NonFungibleTokenModule::create_class(&ALICE, vec![1], ())); + assert_ok!(NonFungibleTokenModule::mint(&BOB, CLASS_ID, vec![1], ())); + }); +} + +#[test] +fn mint_should_fail() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(NonFungibleTokenModule::create_class(&ALICE, vec![1], ())); + Classes::::mutate(CLASS_ID, |class_info| { + class_info.as_mut().unwrap().total_issuance = ::TokenId::max_value(); + }); + assert_noop!( + NonFungibleTokenModule::mint(&BOB, CLASS_ID, vec![1], ()), + Error::::NumOverflow + ); + + NextTokenId::::mutate(|id| *id = ::TokenId::max_value()); + assert_noop!( + NonFungibleTokenModule::mint(&BOB, CLASS_ID, vec![1], ()), + Error::::NoAvailableTokenId + ); + }); +} + +#[test] +fn transfer_should_work() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(NonFungibleTokenModule::create_class(&ALICE, vec![1], ())); + assert_ok!(NonFungibleTokenModule::mint(&BOB, CLASS_ID, vec![1], ())); + assert_ok!(NonFungibleTokenModule::transfer(&BOB, &BOB, (CLASS_ID, TOKEN_ID))); + assert_ok!(NonFungibleTokenModule::transfer(&BOB, &ALICE, (CLASS_ID, TOKEN_ID))); + assert_ok!(NonFungibleTokenModule::transfer(&ALICE, &BOB, (CLASS_ID, TOKEN_ID))); + }); +} + +#[test] +fn transfer_should_fail() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(NonFungibleTokenModule::create_class(&ALICE, vec![1], ())); + assert_ok!(NonFungibleTokenModule::mint(&BOB, CLASS_ID, vec![1], ())); + assert_noop!( + NonFungibleTokenModule::transfer(&BOB, &ALICE, (CLASS_ID, TOKEN_ID_NOT_EXIST)), + Error::::NoPermission + ); + assert_noop!( + NonFungibleTokenModule::transfer(&ALICE, &BOB, (CLASS_ID, TOKEN_ID)), + Error::::NoPermission + ); + assert_noop!( + NonFungibleTokenModule::mint(&BOB, CLASS_ID_NOT_EXIST, vec![1], ()), + Error::::ClassNotFound + ); + }); +} + +#[test] +fn burn_should_work() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(NonFungibleTokenModule::create_class(&ALICE, vec![1], ())); + assert_ok!(NonFungibleTokenModule::mint(&BOB, CLASS_ID, vec![1], ())); + assert_ok!(NonFungibleTokenModule::burn(&BOB, (CLASS_ID, TOKEN_ID))); + }); +} + +#[test] +fn burn_should_fail() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(NonFungibleTokenModule::create_class(&ALICE, vec![1], ())); + assert_ok!(NonFungibleTokenModule::mint(&BOB, CLASS_ID, vec![1], ())); + assert_noop!( + NonFungibleTokenModule::burn(&BOB, (CLASS_ID, TOKEN_ID_NOT_EXIST)), + Error::::TokenNotFound + ); + + assert_noop!( + NonFungibleTokenModule::burn(&ALICE, (CLASS_ID, TOKEN_ID)), + Error::::NoPermission + ); + }); + + ExtBuilder::default().build().execute_with(|| { + assert_ok!(NonFungibleTokenModule::create_class(&ALICE, vec![1], ())); + assert_ok!(NonFungibleTokenModule::mint(&BOB, CLASS_ID, vec![1], ())); + + Classes::::mutate(CLASS_ID, |class_info| { + class_info.as_mut().unwrap().total_issuance = 0; + }); + assert_noop!( + NonFungibleTokenModule::burn(&BOB, (CLASS_ID, TOKEN_ID)), + Error::::NumOverflow + ); + }); +} + +#[test] +fn destroy_class_should_work() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(NonFungibleTokenModule::create_class(&ALICE, vec![1], ())); + assert_ok!(NonFungibleTokenModule::mint(&BOB, CLASS_ID, vec![1], ())); + assert_ok!(NonFungibleTokenModule::burn(&BOB, (CLASS_ID, TOKEN_ID))); + assert_ok!(NonFungibleTokenModule::destroy_class(&ALICE, CLASS_ID)); + }); +} + +#[test] +fn destroy_class_should_fail() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(NonFungibleTokenModule::create_class(&ALICE, vec![1], ())); + assert_ok!(NonFungibleTokenModule::mint(&BOB, CLASS_ID, vec![1], ())); + assert_noop!( + NonFungibleTokenModule::destroy_class(&ALICE, CLASS_ID_NOT_EXIST), + Error::::ClassNotFound + ); + + assert_noop!( + NonFungibleTokenModule::destroy_class(&BOB, CLASS_ID), + Error::::NoPermission + ); + + assert_noop!( + NonFungibleTokenModule::destroy_class(&ALICE, CLASS_ID), + Error::::CannotDestroyClass + ); + + assert_ok!(NonFungibleTokenModule::burn(&BOB, (CLASS_ID, TOKEN_ID))); + assert_ok!(NonFungibleTokenModule::destroy_class(&ALICE, CLASS_ID)); + assert_eq!(Classes::::contains_key(CLASS_ID), false); + }); +}