NFT Marketplace
1. Goal
- The goal is to create a custom Polkadot SDK Pallet that acts as an NFT Marketplace. Our NFTs will represent kitties, which will be a digital pets that can be created, traded, and more.
- This Pallet could then be included into a Polkadot SDK blockchain and used to launch a Web3 application on the Polkadot Network.
2. Runtime
- At the heart of a blockchain is a state transition function (STF). This is the logic of the blockchain, and defines all the ways a blockchain is allowed to manipulate the blockchain state.
- In the polkadot-sdk we refer to this logic as the blockchain's
runtime.
3. NFTs
Non-Fungible Tokens (NFTs) are a type of token which can be created and traded on a blockchain. As their name indicated, each NFT is totally unique, and therefore non-fungible with one another. NFTs can be used for many things, for example: Representing real world assets, Ownership Rights, Access Rights, Digital assets, Music, Images etc.
4. Macros in FRAME
- FRAME uses Macros to simplify the development of Pallets, while keeping all of the benefits of using Rust. You can identify most macros in one of two forms:
#[macro_name]: Attribute macros, which are applied on top of valid rust syntax.macro_name!(...): Declarative macros, which can define their own internal syntax.
The entrypoint for all the FRAME macros looks like this:
#[frame::pallet(dev_mode)]
pub mod pallet {
// -- snip --
}
We wrap all of our Pallet code inside of this entrypoint, which allows our macros to have context of all the details inside. Without this, there would be no way for the macros defined inside the entry point to to communicate information to one another.
The unfortunate limitation here is that wherever we want to use FRAME macros, we must basically do it in a single file and all enclosed by the #[frame::pallet] macro entrypoint.
5. Basic Pallet Structure
use frame::prelude::*;
pub use pallet::*;
#[frame::pallet]
pub mod pallet {
use super::*;
#[pallet::pallet]
pub struct Pallet<T>(core::marker::PhantomData<T>);
#[pallet::config] // snip
#[pallet::event] // snip
#[pallet::error] // snip
#[pallet::storage] // snip
#[pallet::call] // snip
}
5.1 Pallet Struct (#[pallet::pallet])
- The Pallet struct is the anchor on which we implement all logic and traits for our Pallet.
#[pallet::pallet]
pub struct Pallet<T>(core::marker::PhantomData<T>);
// Function implementations
impl<T: Config> Pallet<T> {
// -- snip --
}
// Trait implementations
impl<T: Config> Hooks for Pallet<T> {
fn on_finalize() {
// -- snip --
}
}
// And we can access these functions inside trait implementations as follows:
pallet_kitties::Pallet::::on_finalize();
- In fact, many traits are automatically implemented on top of
Palletand are accessible thanks to the#[pallet::pallet]attribute macro. You can see all the different traits implemented on Pallet by looking at the autogenerated Rust docs.
5.2 Callable Functions (#[pallet::call])
There are two types of functions exposed by Pallets:
- Internal Functions: Regular functions only callable from within the blockchain.
- Callable Functions: The way users interact with the blockchain is through transactions. Those transactions are processed, and then dispatched to callable functions within the blockchain.
5.2.1 Pallet Call Macro
FRAMEallows you to create callable functions by introducing the#[pallet::call]macro on top of a normal function implementation code block.
#[pallet::call]
impl<T: Config> Pallet<T> {
// All of the functions in this `impl` will be callable by users.
}
- The macro enforces rules on how these functions should be defined. They are discussed in the following sections.
- NOTE: "Users submit an extrinsic to the blockchain, which is dispatched to a Pallet call."
- NOTE: An
extrinsicis any message from the outside coming to the blockchain. Atransactionis specifically a signed message coming from the outside.
5.2.2 Origin
- The first parameter of every callable function must be
origin: OriginFor<T>. - It describes where the call is calling from, and allows us to perform simple access control logic based on that information.
- The most common origin is the signed origin, which is a regular transaction.
- Origin is a superset of the idea of
msg.sender(from Smart contract development). No longer do we need to assume that every call to a callable function is coming from an external account. We could have pallets call one another, or other internal logic trigger a callable function.
5.2.3 Dispatch Result
- Every callable function must return a DispatchResult, which is simply defined as:
pub type DispatchResult = Result<(), sp_runtime::DispatchError>;
- So these functions can return
Ok(())or someErr(DispatchError). You can easily define newDispatchErrorvariants using the included#[pallet::error].
5.3 Pallet Config (#[pallet::config])
- Each pallet includes a trait
Configwhich is used to configure the pallet in the context of your larger runtime.
#[pallet::config]
pub trait Config: frame_system::Config {
// -- snip --
}
- Wherever you see
T(from<T: Config>), you have access to ourtrait Configand the types and functions inside of it.
5.4 Pallet Events (#[pallet::event])
- Events allow Pallets to express that something has happened, and allows off-chain systems like indexers or block explorers to track certain state transitions.
- The
#[pallet::event]macro acts on an enum Event.
#[pallet::event]
#[pallet::generate_deposit(pub(super) fn deposit_event)]
pub enum Event<T: Config> {
Created { owner: T::AccountId },
}
- The macro
#[pallet::generate_deposit(pub(super) fn deposit_event)]generates a helper function that handles event conversion and system integration.
Pallet Event // Your event
↓ (From conversion)
Pallet's Runtime Event // Runtime-level event
↓ (Into conversion)
frame_system::Config::RuntimeEvent // System-level event
↓
Stored in block's event log
5.5 Storage (#[pallet::storage])
5.5.1 Storage Values
- The most basic storage type for a blockchain is a single
StorageValue. AStorageValueis used to place a single object into the blockchain storage. A single object can be as simple as a single type like au32, or more complex structures, or even vectors. StorageValueplaces a single entry into the merkle trie. So when you read data, you read all of it. When you write data, you write all of it. This is in contrast to aStorageMap.- Here is how it is created in
FRAME:
#[pallet::storage]
pub(super) type CountForKitties<T: Config> = StorageValue<Value = u32>;
- Our storage (
CountForKitties) is a type alias for a new instance ofStorageValue. StorageValuehas a parameterValuewhere we can define the type we want to place in storage. In this case, it is a simpleu32.- Also notice
CountForKittiesis generic over<T: Config>. All of our storage must be generic over<T: Config>even if we are not using it directly. Macros use this generic parameter to fill in behind the scene details to make theStorageValuework. - Visibility of the type is up to you and your needs, but you need to remember that blockchains are public databases. So
pubin this case is only about Rust, and allowing other modules to access this storage and its APIs directly. You cannot make storage on a blockchain "private", and even if you make this storage withoutpub, there are low level ways to manipulate the storage in the database. StorageValuehas aQueryKindparameter which defaults toOptionQuery. We can change this toValueQueryto return the default values in case the value is not found to get rid of theunwrap_or()in theget()calls. If your type does not implementDefault, you can't useValueQuery.OnEmptytype defines the behavior of theQueryKindtype. When you call get, and the storage is empty, theOnEmptyconfiguration kicks in. You CAN modifyOnEmptyto return a custom value, rather than Default.
5.5.2 Storage Maps
- A
StorageMapis a key-value store. Declaring a new StorageMap is very similar to a StorageValue:
#[pallet::storage]
pub(super) type Kitties<T: Config> = StorageMap<Key = [u8; 32], Value = ()>;
- A
StorageValuestores a single value into a single key in the Merkle Trie. AStorageMapstores multiple values under different storage keys, all into different places in the Merkle Trie. StorageValueputs all of the data into a single object and stores that all into a single key in the Merkle Trie. InStorageMap, each value is stored in its own spot in the Merkle Trie, so you are able to read just one key / value on its own. This can be way more efficient for reading just a single item. However, trying to read multiple items from aStorageMapis extremely expensive.- Ensure:
ensure!is a macro which expands to the following:
ensure!(!Kitties::::contains_key(my_key), Error::::DuplicateKitty);
// | |
// | | This gets expanded to the following:
// V V
if (Kitties::::contains_key(my_key)) {
return Err(Error::::DuplicateKitty.into());
}
- NOTE: In both
StorageValueandStorageMap, theinsertAPI cannot fail. If you try to insert a value into a key that already exists, it will overwrite the existing value. - NOTE: To check if a value exists, you can use the
existsAPI forStorageValueandcontains_keyforStorageMap.
5.5.3 Traits Required for Storage
- When storing a custom struct in runtime storage like the
Kittystruct, you need to implement the following traits on the struct:Encode: The object must be encodable to bytes usingparity_scale_codec.Decode: The object must be decodable from bytes usingparity_scale_codec.MaxEncodedLen: When the object is encoded, it must have an upper limit to its size.TypeInfo: The object must be able to generate metadata describing the object.
- We can use the
#[derive(...)]macros to generate the implementation of these traits.
5.5.4 Parity Scale Codec (parity_scale_codec)
SCALE defines how every object in the polkadot-sdk is represented in bytes. SCALE is:
- Simple to define.
- Not Rust-specific (but happens to work great in Rust).
- Easy to derive codec logic:
#[derive(Encode, Decode)] - Viable and useful for APIs like:
MaxEncodedLenandTypeInfo - It does not use
Rust std, and thus can compile toWasm no_std.
- Easy to derive codec logic:
- Consensus critical / bijective; one value will always encode to one blob and that blob will only decode to that value.
- Supports a copy-free decode for basic types on LE architectures (like Wasm).
- It is about as thin and lightweight as can be.
5.5.5 Max Encoded Length (MaxEncodedLen)
- We track the maximum encoded length of an object:
MaxEncodedLenand use that information to predict in the worst case scenario how much data will be used when we store it. - For a
u8, themax_encoded_len()is always:1 byte. For au64, it is always:8 bytes. For a basicenum, it is also just1 byte, since an enum can represent up to 256 variants. - For a
struct, themax_encoded_len()will be the sum of themax_encoded_len()of all items in the struct.
5.5.6 Type Info (TypeInfo)
TypeInfois a trait that is used to generate metadata about a type.- This trait is key for off-chain interactions with your blockchain. It is used to generate metadata for all the objects and types in your blockchain. Metadata exposes all the details of your blockchain to the outside world, allowing us to dynamically construct APIs to interact with the blockchain.
- Skip Type Params:
TypeInfoderive macro isn't very "smart".TypeInfogenerates relevant metadata about the types used in your blockchain. However, part of ourKittytype is the generic parameterT, and it really does not make any sense to generateTypeInfoforT. To makeTypeInfowork while we haveT, we need to include the additional line:
#[scale_info(skip_type_params(T))]
5.6 Pallet Errors (#[pallet::error])
- NOTE: You cannot panic inside the runtime.
- All of our callable functions use the
DispatchResulttype. TheDispatchResulttype expects eitherOk(())orErr(DispatchError).
// The `DispatchError` type has a few variants that you can easily construct / use.
// For example, if you want to be a little lazy, you can simply return a `&'static str`:
fn always_error() -> DispatchResult {
return Err("this function always errors".into())
}
// But the better option is to return a custom Pallet Error:
fn custom_error() -> DispatchResult {
return Err(Error::::CustomPalletError.into())
}
// Notice in both of these cases we had to call into() to convert our input type
// into the DispatchError type.
- To create
CustomPalletError, simply add a new variants to theenum Error<T>type.
#[pallet::error]
pub enum Error<T> {
/// This is a description for the error.
///
/// This description can be shown to the user in UIs, so make it descriptive.
CustomPalletError,
}
6. Generating Unique DNAs for Kitties
6.1 Randomness
Generating randomness on a blockchain is extremely difficult because any kind of randomness function must generate exactly the same randomness for all nodes. NOTE that Polkadot does provide access to a verifiable random function (VRF).
6.2 Uniqueness
There are different levels of uniqueness we can achieve using data from our blockchain.
frame_system::Pallet::: The hash of the previous block. This will ensure uniqueness for every fork of the blockchain.::parent_hash() frame_system::Pallet::: The number of the current block. This will obviously be unique for each block.::block_number() frame_system::Pallet::: The number of the extrinsic in the block that is being executed. This will be unique for each extrinsic in a block.::extrinsic_index() CountForKitties::: The number of kitties in our blockchain.::get()
6.3 Hash
FRAMEprovides access to the hash function:frame::primitives::BlakeTwo256
// Collect our unique inputs into a single object.
let unique_payload = (item1, item2, item3);
// To use the `hash_of` API, we need to bring the `Hash` trait into scope.
use frame::traits::Hash;
// Hash that object to get a unique identifier.
let hash: [u8; 32] = BlakeTwo256::hash_of(&unique_payload).into();
- The
hash_ofAPI comes from theHashtrait and takes anyencode-able object, and returns aH256, which is a 256-bit hash. As you can see in the code above, it is easy to convert that to a[u8; 32]by just calling.into(), since these two types are equivalent.
7. Redundant Storage (Track Owned Kitties)
As a rule, you only want to store data in your blockchain which is necessary for consensus. We should evaluate what queries we will need to perform on the stored data and then decide that we may indeed need to have redundant storage in order to make the queries efficient.
7.1 Iteration
In general iteration should be avoided where possible, but if unavoidable it is critical that iteration be bounded in size. We literally cannot allow code on our blockchain which would do unbounded iteration, else that would stall our blockchain, which needs to produce a new block on a regular time interval.
- Iteration in Maps: When you iterate over a map, you need to make 2 considerations: That the map may not have a bounded upper limit and That each access to the map is very expensive to the blockchain (each is a unique read to the merkle trie). If you want to do iteration, probably you do NOT want to use a map.
- Iteration in Vectors: When you iterate over a vector, the only real consideration you need to have is how large that vector is. Accessing large files from the database is going to be slower than accessing small files. Once you access the vector, iterating over it and manipulating it is relatively cheap compared to any kind of storage map (but not zero, complexity about vector access still applies). If you want to do iteration, you definitely would prefer to use a vector.
7.2 Storage Optimisations
For vectors, it is not necessary to read data from the storage, append to it and write it back to storage. Instead we can use FRAME's Storage Abstractions and simply append to the vector like below:
// Naive way
// Get/read the vector from storage
let mut owned_kitties: Vec<[u8; 32]> = KittiesOwned::::get(owner);
// Append to the vector
owned_kitties.append(new_kitty);
// Write the vector back to storage
KittiesOwned::::insert(owner, owned_kitties);
// Better way
KittiesOwned::::append(owner, new_kitty);
7.3 Bounded Vectors
- The
BoundedVectype is a zero-overhead abstraction over theVectype allowing us to control the maximum number of item in the vector. To create a new BoundedVec with a maximum of 100 u8s, you can do the following:
let my_bounded_vec = BoundedVec::>::new();
- The syntax here is very similar to creating a
Vec, however we include a second generic parameter which tells us the bound. The easiest way to set this bound is using theConstU32<T>type. - The
BoundedVectype has almost all the same APIs as aVec. The main difference is the fact that aBoundedVeccannot always accept a new item. So rather than havingpush,append,extend,insert, and so on, you havetry_push,try_append,try_extend,try_insert, etc. So converting the logic of aVecto aBoundedVeccan be as easy as:
// Append to a normal vec.
vec.append(item);
// Try append to a bounded vec, handling the error.
bounded_vec.try_append(item).map_err(|_| Error::::TooManyOwned)?;
- Just like for
Vec, ourBoundedVecalso has an optimizedtry_appendAPI for trying to append a new item to theBoundedVecwithout having to read the whole vector in the runtime:
// Append to a normal vec.
KittiesOwned::::append(item);
// Try append to a bounded vec, handling the error.
KittiesOwned::::try_append(item).map_err(|_| Error::::TooManyOwned)?;
8. Pallet Coupling
8.1 What is Pallet Coupling?
Pallet coupling refers to how one Substrate pallet interacts with another. There are two patterns:
- Tight Coupling: Direct dependency on a specific pallet.
- Loose Coupling: Dependency on a trait interface, not a specific pallet.
8.2 Tight Coupling
8.2.1 How It Works
- Supertrait Inheritance: Your pallet's
Configtrait inherits from another pallet'sConfigtrait Example:
#[pallet::config]
pub trait Config: frame_system::Config + pallet_balances::Config { ... }
- Here, your pallet is tightly coupled to both
frame_systemandpallet_balances.
8.2.2 Key Characteristics
- Direct Access: You can use the other pallet's:
- Types: T::AccountId (from frame_system).
- Functions: frame_system::Pallet::
::block_number(). - Storage: Directly read/write to the other pallet's storage.
- Example using pallet_balances:
let total_issuance = pallet_balances::Pallet::::total_issuance();
8.2.3 Pros & Cons
- ✅ Pros:
- Simple to implement.
- Full access to the other pallet's APIs.
- ❌ Cons:
- Rigid dependency: Users of your pallet must use the exact version of the coupled pallet.
- Not modular: Hard to swap out dependencies.
8.2.4 Use Case
- Required for frame_system (all pallets are tightly coupled to it).
- Use when you need deep integration with a specific pallet.
8.3 Loose Coupling
8.3.1 How It Works
- Trait-Based Interface: Define an associated type in your pallet's Config that requires specific traits. Example:
#[pallet::config]
pub trait Config: frame_system::Config {
type NativeBalance: Inspect + Mutate;
}
- Here,
NativeBalancemust implementfungible::Inspectandfungible::Mutatetraits.
8.3.2 Key Characteristics
- Trait-Bound Access: Use the other pallet's functionality through traits:
let balance = T::NativeBalance::total_balance(alice);
T::NativeBalance::mint_into(alice, amount)?;
- No Direct Dependency: The runtime can assign any pallet that implements the required traits.
8.3.3 Pros & Cons
- ✅ Pros:
- Flexible: Users can swap out the underlying pallet (e.g., use pallet_assets instead of pallet_balances).
- Decouples versioning: No strict dependency on a specific pallet version.
- ❌ Cons:
- Limited to the functionality defined by the traits.
8.3.4 Use Case
- Use when you want your pallet to work with multiple implementations (e.g., different token standards).
- Common for fungible tokens, NFTs, or other generic functionalities.
8.4 Runtime Configuration Example
- Tight Coupling Setup
// In your runtime (`runtime/src/lib.rs`):
impl pallet_kitties::Config for Runtime {
type RuntimeEvent = RuntimeEvent;
// Tight coupling to `pallet_balances` is implicit via the `Config` trait.
}
- Loose Coupling Setup
// In your runtime (`runtime/src/lib.rs`):
impl pallet_kitties::Config for Runtime {
type RuntimeEvent = RuntimeEvent;
type NativeBalance = pallet_balances::Pallet; // Assign `pallet_balances`
}
- Here, pallet_balances implements Inspect and Mutate, so it satisfies the trait bounds.
9. Balance Type
- The Balance type is ultimately configured inside
pallet_balances, and we don't have direct access to that pallet because we used loose coupling. - The way we can access the Balance type is through the
Inspecttrait of theNativeBalanceassociated type. Accessing it kind of funny, which is why we commonly introduce aBalanceOf<T>alias type like so:
// Allows easy access our Pallet's `Balance` type. Comes from `Fungible` interface.
pub type BalanceOf =
<::NativeBalance as Inspect<::AccountId>>::Balance;
BalanceOf<T>type is generic, which means we CANNOT write the following:
// This code doesn't work
fn add_one(input: BalanceOf) -> BalanceOf {
input + 1u128
}
BalanceOf<T>does have traits that we can use to interact with it. The key one beingAtLeast32BitUnsigned. This means ourBalanceOf<T>must be anunsigned integer, and must be at leastu32. So it could beu32,u64,u128, or even bigger. This also means we would be able to write the following:
// This code does work
fn add_one(input: BalanceOf) -> BalanceOf {
input + 1u32.into()
}
- We can convert any
u32into theBalanceOf<T>type because we know at a minimumBalanceOf<T>isAtLeast32BitUnsigned. - BalanceOf<T> types will act just like two normal numbers of the same type. You can add them, divide them, etc. and even better, do safe math operations on all of them:
let total_balance: BalanceOf = balance_1.checked_add(balance_2).ok_or(ArithmeticError::Overflow)?;
10. Buy and Sell Kitties
To execute a purchase, we need to transfer two things:
- The token balance from the buyer to the seller.
- The kitty from the seller to the buyer.
10.1 Transfer the Native Balance
- We already have a function to transfer kitties. To transfer the
NativeBalance, you can use thetransfer APIwhich is included in thefungible::Mutatetrait:
fn transfer(
source: &AccountId,
dest: &AccountId,
amount: Self::Balance,
preservation: Preservation
) -> Result
- NOTE: To access this function, you will need import the trait to bring it in scope. To use functions defined within traits, you need to bring the trait into scope. This is a rust thing.
- The first 3 parameters here are easy enough to understand. We also have a 4th parameter which is
preservation: Preservation. It is defined as follows:
/// The mode by which we describe whether an operation should keep an account alive.
pub enum Preservation {
/// We don't care if the account gets killed by this operation.
Expendable,
/// The account may not be killed, but we don't care if the balance gets dusted.
Protect,
/// The account may not be killed and our provider reference must remain (in the context of
/// tokens, this means that the account may not be dusted).
Preserve,
}
10.2 Propagate Up Errors
- Both transfer functions need to succeed for the sale to complete successfully. If either one of them would fail, the whole purchase should fail.
- Thankfully, both of our transfer functions return a result, and to handle things correctly here, we just need to propagate up those errors. For that, we simply include
?at the end of the function. - If at any point our extrinsic or the logic inside the extrinsic returns an error, the whole extrinsic will fail and all changes to storage will be undone. This is exactly the same behavior you would expect from a smart contract, and keeps our state transition function functioning smoothly.
11. Associated Types v/s Trait Methods
11.1 For trait methods (like transfer):
- When a trait method is called, Rust can automatically figure out the trait bounds and associated types through type inference.
- That's why
T::NativeBalance::transfer(...)works - Rust knows thatT::NativeBalanceimplementsMutatefrom theConfigtrait bounds, and can infer the correctAccountIdtype.
11.2 For associated types (like Balance):
- When accessing an associated type, Rust needs explicit information about which trait provides that associated type.
Balanceis an associated type that comes from theInspecttrait, not directly fromNativeBalance.- Just writing
T::NativeBalance::Balancedoesn't tell Rust which trait'sBalancewe want to use. - That's why we need the fully qualified syntax for the type alias.