Substrate kitties(NFT)

튜토리얼 목표

  1. substrate node를 설계하고 실행하는 기본 패턴들을 익히기
  2. Custom FRAME pallet을 작성하고 나의 runtime에 통합하기
  3. storage items를 생성하고 업데이트 하는 법
  4. pallet extrinsics 와 helper functions 작성
  5. PolkadotJS API를 사용해서 substrate node를 front-end에 연결

kitties 특징

  • original source 나 이미 존재하는 kitties들에 의해 생성된다.
  • 주인에 의해 정해진 가격으로 거래 된다.
  • 한 주인으로 부터 다른 주인에게 전송될 수 있다.

Temlate file
템플릿 파일을 이용해서 각 파트를 완성시키는 데 도움을 받을 수 있습니다.


Basic setup

Substrate node template은 커스터마이징 가능한 blockchain node를 built-in networking 그리고 consensus layer와 함께 제공해준다.
이 튜토리얼에서는 runtime 과 pallets의 로직에만 집중함.

'kickstart' CLI를 툴을 cargo를 통해 설치하고 해당 github주소로 부터 substrate node template의 가장 최신 copy를 받아온다.
cargo install kickstart
kickstart https://github.com/sacha-l/kickstart-substrate
node이름과 pallet이름은 kitties로 설정하면 된다.

해당 directory에는 세 개의 주요 하위 directory들이 있다.
1. 'node' - 나의 node가 나의 runtime 그리고 RPC clients와 상호작용을 가능하게 하는 로직을 포함
2. 'pallets' - 나의 모든 custom pallet들이 있는 곳
3. 'runtime' - 모든 pallet들이 chain의 runtime에 통합되고 시행되는 폴더

pallet_kitties 작성하기

kitties_tutorial/pallets/kitties/src 경로로 이동해서 lib.rs를 제외한 모든 파일을 삭제하고 lib.rs내부의 코드도 모두 지운다.

Substrate에서 pallet은 runtime로직을 정의하는 데 사용된다. 이 튜토리얼의 경우에는 substrate kitties의 모든 로직을 관리하는 하나의 pallet를 만드는 것이다.

모든 FRAME pallet은 frame_support와 frame_system이라는 dependencies를 포함하고, attribute macros를 필요로한다.(Rust에서 macro는 code를 작성하는 code임 - metacode.)

  1. lib.rs의 코드를 아래 코드로 대체 시켜주고
#![cfg_attr(not(feature = "std"), no_std)]

pub use pallet::*;

#[frame_support::pallet]
pub mod pallet {
    use frame_support::{
        sp_runtime::traits::{Hash, Zero},
        dispatch::{DispatchResultWithPostInfo, DispatchResult},
        traits::{Currency, ExistenceRequirement, Randomness},
        pallet_prelude::*
    };
    use frame_system::pallet_prelude::*;
    use sp_io::hashing::blake2_128;

    // TODO Part II: Struct for holding Kitty information.

    // TODO Part II: Enum and implementation to handle Gender type in Kitty struct.

    #[pallet::pallet]
    #[pallet::generate_store(pub(super) trait Store)]
    pub struct Pallet<T>(_);

    /// Configure the pallet by specifying the parameters and types it depends on.
    #[pallet::config]
    pub trait Config: frame_system::Config {
        /// Because this pallet emits events, it depends on the runtime's definition of an event.
        type Event: From<Event<Self>> + IsType<<Self as frame_system::Config>::Event>;

        /// The Currency handler for the Kitties pallet.
        type Currency: Currency<Self::AccountId>;

        // TODO Part II: Specify the custom types for our runtime.

    }

    // Errors.
    #[pallet::error]
    pub enum Error<T> {
        // TODO Part III
    }

    #[pallet::event]
    #[pallet::generate_deposit(pub(super) fn deposit_event)]
    pub enum Event<T: Config> {
        // TODO Part III
    }

    // ACTION: Storage item to keep a count of all existing Kitties.

    // TODO Part II: Remaining storage items.

    // TODO Part III: Our pallet's genesis configuration.

    #[pallet::call]
    impl<T: Config> Pallet<T> {

        // TODO Part III: create_kitty

        // TODO Part III: set_price

        // TODO Part III: transfer

        // TODO Part III: buy_kitty

        // TODO Part III: breed_kitty
    }

    // TODO Part II: helper function for Kitty struct

    impl<T: Config> Pallet<T> {
        // TODO Part III: helper functions for dispatchable functions

        // TODO: increment_nonce, random_hash, mint, transfer_from

    }
}
  1. pallet directory의 Cargo.toml에 sp-io의 dependency가 있는 지 확인한다.
    없으면 추가
[dependencies.sp-io]
default-features = false
git = 'https://github.com/paritytech/substrate.git'
tag = 'devhub/latest'
version = '4.0.0-dev'
  1. cargo build -p pallet-kitties 로 에러가 없는 지 확인한다.

내가 build를 했을 때는 node-template-runtime 패키지를 찾을 수 없다는 에러가 발생했는데 이는 substrate-node-template의 source code를 copy하며 생긴 문제로 보였다. node directory의 Cargo.toml을 보면 Local dependencies라고 주석 처리가 된 부분이 있다. 이 아래의 node-template-runtime dependency를 없애주면 원활하게 build가 진행된다.

Storage item 추가

ACTION 주석을 다음의 코드로 대체한다.
어떤 storage item을 선언하기 위해서는 #[pallet::storage]를 사전에 포함시켜줘야한다.

#[pallet::storage]
#[pallet::getter(fn kitty_cnt)]
/// Keeps track of the number of Kitties in existence.
pub(super) type KittyCnt<T: Config> = StorageValue<_, u64, ValueQuery>;

Currency implementation

node를 설계하기 앞서, Currency type을 runtime implementation에 추가시켜줘야한다. runtime/src/lib.rs로 이동해서 pallet_kitties::Config trait을 적용하는 부분을 찾고 다음 코드를 추가해준다.

impl pallet_kitties::Config for Runtime {
    type Event = Event;
    type Currency = Balances; // <-- Add this line
}

Uniqueness, Custom types and storage maps

이제 FRAME pallets를 개발을 위한 몇개의 개념을 좀 더 들여다 보자(참고로 FRAME이란 Framework for Runtime Aggregation of Modularized Entities의 약어이다.)
기존의 lib.rs 코드를 이 템플릿으로 교체하자

#![cfg_attr(not(feature = "std"), no_std)]

pub use pallet::*;

#[frame_support::pallet]
pub mod pallet {
    use frame_support::pallet_prelude::*;
    use frame_system::pallet_prelude::*;
    use frame_support::{
        sp_runtime::traits::Hash,
        traits::{ Randomness, Currency, tokens::ExistenceRequirement },
        transactional
    };
    use sp_io::hashing::blake2_128;

    #[cfg(feature = "std")]
    use frame_support::serde::{Deserialize, Serialize};

    // ACTION #1: Write a Struct to hold Kitty information.

    // ACTION #2: Enum declaration for Gender.

    // ACTION #3: Implementation to handle Gender type in Kitty struct.

    #[pallet::pallet]
    #[pallet::generate_store(pub(super) trait Store)]
    pub struct Pallet<T>(_);

    /// Configure the pallet by specifying the parameters and types it depends on.
    #[pallet::config]
    pub trait Config: frame_system::Config {
        /// Because this pallet emits events, it depends on the runtime's definition of an event.
        type Event: From<Event<Self>> + IsType<<Self as frame_system::Config>::Event>;

        /// The Currency handler for the Kitties pallet.
        type Currency: Currency<Self::AccountId>;

        // ACTION #5: Specify the type for Randomness we want to specify for runtime.

        // ACTION #9: Add MaxKittyOwned constant
    }

    // Errors.
    #[pallet::error]
    pub enum Error<T> {
        // TODO Part III
    }

    // Events.
    #[pallet::event]
    #[pallet::generate_deposit(pub(super) fn deposit_event)]
    pub enum Event<T: Config> {
        // TODO Part III
    }

    #[pallet::storage]
    #[pallet::getter(fn kitty_cnt)]
    pub(super) type KittyCnt<T: Config> = StorageValue<_, u64, ValueQuery>;

    // ACTION #7: Remaining storage items.

    // TODO Part IV: Our pallet's genesis configuration.

    #[pallet::call]
    impl<T: Config> Pallet<T> {

        // TODO Part III: create_kitty

        // TODO Part IV: set_price

        // TODO Part IV: transfer

        // TODO Part IV: buy_kitty

        // TODO Part IV: breed_kitty
    }

    //** Our helper functions.**//

    impl<T: Config> Pallet<T> {

        // ACTION #4: helper function for Kitty struct

        // TODO Part III: helper functions for dispatchable functions

        // ACTION #6: function to randomly generate DNA

        // TODO Part III: mint

        // TODO Part IV: transfer_kitty_to
    }
}

그런 다음 이 dependencies를 추가해준다.

[dependencies.serde]
version =  '1.0.129'

Kitty struct

kitty는 다음과 같은 field를 필요로 한다.

  • dna : kitty의 DNA를 식별하는 hash 값. 이 DNA는 새로운 kitties를 기르는 데 사용되고 다른 세대를 추적하는 데 사용할 수 있다.
  • price : kitty를 거래하기 위한 owner에 의해 정해진 가격
  • gender : variants로 male과 female을 가지는 enum
  • owner: account id

kitty struct를 작성하기 전에 먼저 type aliasing 을 통해 AccountOf<T> type과 BalanceOf<T>type을 작성한다.

	type AccountOf<T> = <T as frame_system::Config>::AccountId;
	type BalanceOf<T> =
		<<T as Config>::Currency as Currency<<T as frame_system::Config>::AccountId>>::Balance;

예를 들어 AccountOf<T>frame_system::Config trait의 associated type인 AccountId와 동일하다.
그 다음 ACTION1 을 다음의 코드로 대체시킨다.

// ACTION #1: Write a Struct to hold Kitty information.
	#[derive(Clone, Encode, Decode, PartialEq, RuntimeDebug, TypeInfo)]
	#[scale_info(skip_type_params(T))]
	pub struct Kitty<T: Config> {
		pub dna: [u8; 16],
		pub price: Option<BalanceOf<T>>,
		pub gender: Gender,
		pub owner: AccountOf<T>,
	}

Gender enum

ACTION2주석을 이 코드로 대체한다.
여기서 주의할 것은 derive macro가 enum의 declaration이전에 사용되어야 한다는 것

Derive macro란?
Derive macros define new inputs for the derive attribute. These macros can create new items given the token stream of a struct, enum, or union. Custom derive macros are defined by a public function with the proc_macro_derive attribute and a signature of (TokenStream) -> TokenStream
e.g.
This derive(Clone, Encode, ...) create impl item for these traits for enum 'Gender'

#[derive(Clone, Encode, Decode, PartialEq, RuntimeDebug, TypeInfo)]
#[scale_info(skip_type_params(T))]
#[cfg_attr(feature = "std", derive(Serialize, Deserialize))]
pub enum Gender {
    Male,
    Female,
}

helper function implementation

이 튜토리얼에서는 kitty의 DNA에 따라 Gender를 설정하는 방식을 따른다.
다음의 public function 'gen_gender'는 Gender type을 return하고 Variants를 선택하기 위해 random function을 사용한다.
ACTION4를 이 코드로 대체한다.

fn gen_gender() -> Gender {
    let random = T::KittyRandomness::random(&b"gender"[..]).0;
    match random.as_ref()[0] % 2 {
        0 => Gender::Male,
        _ => Gender::Female,
    }
}

위 코드에서 impl의 generic인 <T: config>가 생략됐는데, on-chain상에서 kitty들에게 따로 적용하기 위해서는 config trait에 KittyRandomness를 추가시켜줘야 한다.
Randomness trait은 frame_support로부터 import되었다. (mod 코드의 상단을 보면 import된 것을 확인가능)
Randomness trait의 docs를 확인하면 이런 구조다.

pub trait Randomness<Output, BlockNumber> {
    fn random(subject: &[u8]) -> (Output, BlockNumber);

    fn random_seed() -> (Output, BlockNumber) { ... }
}

이것을 KittyRandomness에 맞추어 Output은 Hash 값으로 BlockNumber는 그대로 내보낸다.

    /// Configure the pallet by specifying the parameters and types it depends on.
    #[pallet::config]
    pub trait Config: frame_system::Config {
        /// Because this pallet emits events, it depends on the runtime's definition of an event.
        type Event: From<Event<Self>> + IsType<<Self as frame_system::Config>::Event>;

        /// The Currency handler for the Kitties pallet.
        type Currency: Currency<Self::AccountId>;

        // ACTION #5: Randomness를 구체화하기
		type KittyRandomness: Randomness<Self::Hash, Self::BlockNumber>;
        // ACTION #9: Add MaxKittyOwned constant
    }

원래는 KittyRandomness type을 RandomnessCollectiveFlip의 instance로 설정 해줘야 하는데 node template이 이 작업을 미리 설정해놓았기 때문에 KittyRandomness type을 runtime code에 추가시켜주는 작업만 하면 된다.
runtime/src/lib.rs

impl pallet_kitties::Config for Runtime {
    type Event = Event;
    type Currency = Balances;
    type KittyRandomness = RandomnessCollectiveFlip; // <-- ACTION: add this line.
}

다음으로는 gen_dna function을 만든다
ACTION6 주석을 다음 코드로 대체

fn gen_dna() -> [u8; 16] {
    let payload = (
        T::KittyRandomness::random(&b"dna"[..]).0,
        <frame_system::Pallet<T>>::block_number(),
    );
    payload.using_encoded(blake2_128)
}

참고로 blake2_128은 use sp_io::hashing::blake2_128;여기서 import한 것이다.

남은 storage items 작성

kitties를 쉽게 추적하기 위해서 하나의 유일한 key가 kitty object를 가르키도록 한다. 이를 위해서 항상 새로운 kitty를 위한 id가 유일한지 아닌지를 확인해야함.
해쉬화된 id값을 kitty object에 mapping한 storage item인 Kitties를 구체화해서 이것을 해결 가능
여기서 runtime은 두 가지를 인지해야하는데
1. unique assets(currency나 kitties같은)
2. 이 assets관련한 소유권(account id같은)
1번은 Kitties storage map으로 , 2번은 KittiesOwned라는 새로운 storage map으로 해결가능

먼저 1번부터,
참고로 StorageMap은 FRAME에 의해 제공되는 hash-map임. 구조는 기존의 HashMap과 유사한 key-value pair.
pub뒤에 super는 해당 아이템의 가시성을 제한하는 것인데, (in path), (crate), (self) , (super) 여러가지가 있지만 super의 경우는 해당 아이템을 parent 모듈에게 보이도록 하는 것이다.
Twox64Concat은 hashing 알고리즘
T::Hash는 key의 type
Kitty<T>는 value의 type이다

#[pallet::storage]
#[pallet::getter(fn kitties)]
pub(super) type Kitties<T: Config> = StorageMap<
    _,
    Twox64Concat,
    T::Hash,
    Kitty<T>,
>;

그 다음 2번
대부분 유사한데, kitties의 최대 숫자를 추적하기위해 BoundedVec을 썻다는 것이 차이점

#[pallet::storage]
#[pallet::getter(fn kitties_owned)]
/// Keeps track of what accounts own what Kitty.
pub(super) type KittiesOwned<T: Config> = StorageMap<
    _,
    Twox64Concat,
    T::AccountId,
    BoundedVec<T::Hash, T::MaxKittyOwned>,
    ValueQuery,
>;

마찬가지로 generic T가 Config에 bound되어있고 우리는 config trait으로 부터 MaxKittyOwned를 사용하고 싶으니 Config trait에 type을 명시해줘야한다

    #[pallet::config]
    pub trait Config: frame_system::Config {
        /// Because this pallet emits events, it depends on the runtime's definition of an event.
        type Event: From<Event<Self>> + IsType<<Self as frame_system::Config>::Event>;

        /// The Currency handler for the Kitties pallet.
        type Currency: Currency<Self::AccountId>;

        // ACTION #5: Specify the type for Randomness we want to specify for runtime.
		type KittyRandomness: Randomness<Self::Hash, Self::BlockNumber>;
        // ACTION #9: Add MaxKittyOwned constant
        #[pallet::constant]
        type MaxKittyOwned: Get<u32>;
    }

그리고 또 config에 새로운 type을 명시했으니? runtime에서도 바꿔줘야 한다.

parameter_types! {
	pub const TransactionByteFee: Balance = 1;
	pub OperationalFeeMultiplier: u8 = 5;
	pub const MaxKittyOwned: u32 = 9999; // 여기서 parameter type명시
}

impl pallet_kitties::Config for Runtime {
	type Event = Event;
	type Currency = Balances;
	type KittyRandomness = RandomnessCollectiveFlip; // <-- ACTION: add this line.
	type MaxKittyOwned = MaxKittyOwned; // <- add this line
}

Disparchables, events and errors

이제 kitty의 토대를 다져놨으니, 다시 세 단계로 분할해서 나머지 과정을 진행한다.
1. create_kitty : a dispatchable , callable한 function이다. account가 kitty를 민팅하는 것을 허용함
2. mint() : pallet storage items 업데이트를 도와주는 function + error checking을 수행함
3. pallet Events: #[pallet::event] attribute를 사용한다.

코드를 짜기 전에, 먼저 create_kitty와 mint function의 동작 방식을 보고 가자.

  • create_kitty(dispatchable or extrinsic function)
    origin이 signed인지 확인함 -> 맞으면 signing account와 random hash를 생성 -> random hash로 새로운 kitty 생성 -> private mint function을 콜

  • mint(private helper function)
    kitty가 이미 존재하는지 확인함 -> 아니라면 새로운 kitty의 id로 storage 업데이트
    -> 총 kitties 숫자와 새로운 owner의 계정을 업데이트 -> 새로운 kitty가 성공적으로 생성되었다는 event

create_kitty 작성하기

모든 pallet dispatchables는 #[pallet::call] macro 아래에 작성된다.
이 매크로를 통해서 runtime에 pallet이 통합되도록 하는 코드를 작성하는 것을 줄일 수 있다.
모든 dispatchable function은 관련된 weight(가중치)를 가져야 한다.
Weight라는 개념은 substrate 개발에 있어 중요한 부분이므로 기억하자
Substrate weighting system은 개발자가 각각의 extrinsic이 call당하기 전에 수행하는 computational 복잡성을 생각하도록한다.
extrinsic은 체인 밖에서 와서 블록에 포함되는 하나의 정보 조각 이라고 보면됌(참고로 events는 intrinsic임).
이렇게 weight를 사용해야 node가 실행 time의 최악의 경우를 처리하게 할 수 있음.
더 자세한 것은 docs에서 확인할 것..

이 튜토리얼은 디폴트로 가중치를 100으로 설정

앞에 과정들을 잘 진행했으니 다시 템플릿 코드로 교체해줍시다
helper template
위에 링크로부터 코드를 복사해서 원래 pallet lib.rs파일을 대체해주면 됌

이제 create_kitty function을 작성한다.
여기서 log::info는 pallet이 예상한대로 작동했는지 확인하는데 도움을 준다.

	#[pallet::call]
	impl<T: Config> Pallet<T> {
		/// Create a new unique kitty.
		///
		/// The actual kitty creation is done in the `mint()` function.
		#[pallet::weight(100)]
		pub fn create_kitty(origin: OriginFor<T>) -> DispatchResult {
            let sender = ensure_signed(origin)?; // <- add this line
            let kitty_id = Self::mint(&sender, None, None)?; // <- add this line
            // Logging to the console
            log::info!("A kitty is born with ID: {:?}.", kitty_id); // <- add this line
        
            // ACTION #4: Deposit `Created` event
			Ok(())
		}

		// TODO Part IV: set_price

		// TODO Part IV: transfer

		// TODO Part IV: buy_kitty

		// TODO Part IV: breed_kitty
	}

Cargo.toml에 dependencies추가해주기

[dependencies.log]
default-features = false
version = '0.4.14'

mint function 작성하기

mint는 세 가지 arguments가 필요하다.
1. owner - &T::AccountId
2. dna - Option<[u8; 16]>
3. gender - Option<Gender>

mint code

// Helper to mint a Kitty.
pub fn mint(
    owner: &T::AccountId,
    dna: Option<[u8; 16]>,
    gender: Option<Gender>,
) -> Result<T::Hash, Error<T>> {
    let kitty = Kitty::<T> {
        dna: dna.unwrap_or_else(Self::gen_dna),
        price: None,
        gender: gender.unwrap_or_else(Self::gen_gender),
        owner: owner.clone(),
    };

    let kitty_id = T::Hashing::hash_of(&kitty);

    // Performs this operation first as it may fail
    let new_cnt = Self::kitty_cnt().checked_add(1)
        .ok_or(<Error<T>>::KittyCntOverflow)?;

    // Performs this operation first because as it may fail
    <KittiesOwned<T>>::try_mutate(&owner, |kitty_vec| {
        kitty_vec.try_push(kitty_id)
    }).map_err(|_| <Error<T>>::ExceedMaxKittyOwned)?;

    <Kitties<T>>::insert(kitty_id, kitty);
    <KittyCnt<T>>::put(new_cnt);
    Ok(kitty_id)
}
  1. Kitty struct의 새로운 instance를 생성 -> kitty_id를 생성
    Self::kitty_cnt()를 통해 KittyCnt를 증가시키고 checked_add method로 overflow가 있는지 검사

  2. 이제 storage item을 업데이트하면 됌

  • try_mutate method로 kitty owner의 vector를 업데이트
  • StorageMap API에 내장된 insert method로 Kitty와 kitty_id 업데이트
  • StorageValue API에 내장된 put method로 가장 최근의 kitty 카운트를 저장

Pallet Events

이벤트를 사용하기 위해 먼저 config에 type을 추가해준다.

#[pallet::config]
	pub trait Config: frame_system::Config {
		
        //--snip--//

        /// Because this pallet emits events, it depends on the runtime's definition of an event.
        type Event: From<Event<Self>> + IsType<<Self as frame_system::Config>::Event>;
	}

그 다음 Event enum을 생성한다.
이벤트를 선언할 때는 #[pallet::event]가 필요함을 기억하자.
#[pallet::generate_deposit(pub(super) fn deposit_event)]란 매크로는 특정한 이벤트를 deposit하는 것을 허용해준다.

	// Events.
	#[pallet::event]
	#[pallet::generate_deposit(pub(super) fn deposit_event)]
	pub enum Event<T: Config> {
		// ACTION #3: Declare events
        /// A new Kitty was successfully created. \[sender, kitty_id\]
        Created(T::AccountId, T::Hash),
        /// Kitty price was successfully set. \[sender, kitty_id, new_price\]
        PriceSet(T::AccountId, T::Hash, Option<BalanceOf<T>>),
        /// A Kitty was successfully transferred. \[from, to, kitty_id\]
        Transferred(T::AccountId, T::AccountId, T::Hash),
        /// A Kitty was successfully bought. \[buyer, seller, kitty_id, bid_price\]
        Bought(T::AccountId, T::AccountId, T::Hash, BalanceOf<T>),
	}

위에서 매크로로 deposit을 허용해주었기 때문에
다음의 코드를 사용 가능(create_kitty function에 추가해준다.)

Self::deposit_event(Event::Created(sender, kitty_id));

에러 처리

FRAME은 error handling을 위해서 #[pallet::error] attribute를 제공한다.
아래에 발생 가능한 모든 에러의 variants를 명시해주었다.

	#[pallet::error]
	pub enum Error<T> {
		/// Handles arithmetic overflow when incrementing the Kitty counter.
        KittyCntOverflow,
        /// An account cannot own more Kitties than `MaxKittyCount`.
        ExceedMaxKittyOwned,
        /// Buyer cannot be the owner.
        BuyerIsKittyOwner,
        /// Cannot transfer a kitty to its owner.
        TransferToSelf,
        /// Handles checking whether the Kitty exists.
        KittyNotExist,
        /// Handles checking that the Kitty is owned by the account transferring, buying or setting a price for it.
        NotKittyOwner,
        /// Ensures the Kitty is for sale.
        KittyNotForSale,
        /// Ensures that the buying price is greater than the asking price.
        KittyBidPriceTooLow,
        /// Ensures that an account has enough funds to purchase a Kitty.
        NotEnoughBalance,
	}

kitties와 상호작용하기

좋은 웹페이지 즐겨찾기