The original version from Substrate Developer Hub is here, please give a few credits to the team behind it: https://www.shawntabrizi.com/substrate-collectables-workshop/#/
The interactive hands-on build-your-first-blockchain with Substrate workshop
The original version is outdated. substrate-node-template
no longer has a concept of runtime modules but pallet
. Hence, it is not preferable to us outdated material to learn about Substrate.
This is an interactive hands-on self-paced workshop. You will learn how to build your first blockchain using Substrate, the OpenSource Rust Blockchain Development Kit by Parity. Through the lessons of the workshop, you will build a collectibles blockchain -- a chain that creates assets and allows you to interact with and manage ownership of them.
As such, this material will focus on building the logic of this chain. It won't cover the networking, consensus, or economic incentive aspects of blockchains. Fortunately, Substrate comes with decent networking and consensus engines built in, so we can just focus on the chain logic.
Substrate is built using Rust, a modern statically typed systems programming language. We won't go into the details of the language within this workshop. The language is quite easy to read and follow and if you have programmed before, you shouldn't have too much trouble following what is going on and finishing the exercises even if Rust is new to you.
We are going to build a simple NFT marketplace for Substrate Kitties
(Please take a look at CryptoZombies or CryptoKitties on the Ethereum blockchain to get an idea of what is Substrate Kitties) that allows users to:
mint
: Mint a new NFT item (we call it a Kitty)transfer
: Transfer a new NFT item from the sender to a destination account.list_nft
: List the NFT on the marketplace so other users can buybuy_nft
: User can buy NFT on the marketplace from other users if the NFT is listed
This requires you to finish the first few tutorials of Substrate development from the official documentation. If you have not walked through those first. Please take a look at these first before diving deeper into this interactive tutorial:
- TheLowLevelers - Run a local Substrate Node (Vietnamese)
- Substrate Tutorial - Build a local blockchain
- Substrate Tutorial - Pallet
If your hardware is a modern M1 Apple silicon chip, working with Substrate can be very painful because there is many unstable compilation issue happens during your development. To avoid this, please install the Rust toolchain following the versions below.
β― cargo --version
cargo 1.76.0-nightly (71cd3a926 2023-11-20)
β― rustc --version
rustc 1.76.0-nightly (3a85a5cfe 2023-11-20)
β― rustup --version
rustup 1.25.2 (17db695f1 2023-02-01)
There are multiple versions of this awesome Substrate Kitties tutorial. However, based on my experience, those are outdated and it takes you a lot of time to set up the right dependency version to work on.
So please checkout 1-setup
to get a well-tested code template for this tutorial.
git clone https://github.com/lowlevelers/substrate-kitites.git
git checkout 1-setup
After checking the branch, please run the below command to test if you can run a node from your local environment.
cd substrate-kitties
cargo build --release
Let's break down the given template code and what you need to work on:
I will suppose that you already understand the structure of a Pallet code and how Pallet interacts with the Substrate Runtime
The full flow for Substrate development will be Pallet > Runtime > Frontend
Step | Status | Modules | Description |
---|---|---|---|
#0 | β | Prerequisites | Prepare your local environment to work with Substrate node and the code template |
#1 | β | 1-setup | Clone substrate-kitties and checkout branch 1-setup to setup the template code on the local |
#2 | β | 2-data-structure | Learn about Pallet storage and write basic data structures for Substrate Kitties |
#3 | β | 3-mint-kitty | Learn about dispatchable functions, event and write a method to mint a new kitty |
#4 | β | 4-onchain-randomness | Learn about onchain randomness and how to generate a random DNA for the Kitty |
#5 | β | 5-frontend | Interact with the Substrate Node from the frontend. |
#6 | β | 6-full-code | Implement a full code for Substrate Kitties project |
#7 | β | 7-pallet-nft | Migrate to production-ready pallet-nft |
#8 | π‘ | 8-nft-auction | Build NFT Auction for Substrate Kitties NFT |
#9 | π‘ | 9-soulbound-nft | Implement non-transferrable attribute to the Substrate Kitties NFT |
I would recommend you to read these materials below first before looking at the code implmentation of the data structures. These materials below cover very well the concepts of FRAME storage in Substrate development.
The FRAME Storage module simplifies access to these layered storage abstractions. You can use the FRAME storage data structures to read or write any value that can be encoded by the SCALE codec. The storage module provides the following types of storage structures:
- StorageValue to store any single value, such as a u64.
- StorageMap to store a single key to value mapping, such as a specific account key to a specific balance value.
- StorageDoubleMap to store values in a storage map with two keys as an optimization to efficiently remove all entries that have a common first key.
- StorageNMap to store values in a map with any arbitrary number of keys.
The blow type alias BalanceOf
llows easy access our Pallet's Balance
type. Comes from Currency
interface.
type BalanceOf<T> =
<<T as Config>::Currency as Currency<<T as frame_system::Config>::AccountId>>::Balance;
Struct for holding kitty information. You may notice a few macros used for the below struct like Encode
, Decode
, TypeInfo
, MaxEncodedLen
. Let's break down the use of these macros.
Encode
,Decode
: Macros inparity-scale-codec
which allows the struct to be serialized to and deserialized from binary format with SCALE.MaxEncodedLen
: By default the macro will try to bound the types needed to implementMaxEncodedLen
, but the bounds can be specified manually with the top level attribute.TypeInfo
: Basically, Rust macros are not that intelligent. In the case of the TypeInfo derive macro, we parse the underlying object, and try to turn it into some JSON expressed type which can be put in the metadata and used by front-ends. (Read more Substrate Stack Exchange - What is the role of#[scale_info(skip_type_params(T))]
?)
#[derive(Clone, Encode, Decode, PartialEq, RuntimeDebug, TypeInfo, MaxEncodedLen, Copy)]
#[scale_info(skip_type_params(T))]
pub struct Kitty<T: Config> {
// [2-data-structure]: Implement other attributes for the Kitty struct
pub dna: T::Hash,
pub price: Option<BalanceOf<T>>,
pub gender: Gender,
pub owner: T::AccountId,
}
The Rust macros for automatically deriving MaxEncodedLen naively thinks that T must also be bounded by MaxEncodedLen, even though T itself is not being used in the actual types. (Read more)
Another way to do this without macros like TypeInfo
and #[scale_info(skip_type_params(T))]
is to pass in the generic type for T::AccountId
and T::Hash
directly instead of pointing them from the genenric T
type (which does not implement MaxEncodedLen
).
// The Gender type used in the `Kitty` struct
#[derive(Clone, Encode, Decode, PartialEq, Copy, RuntimeDebug, TypeInfo, MaxEncodedLen)]
pub enum Gender {
Male,
Female,
}
impl<T: Config> Kitty<T> {
pub fn generate_gender(random_hash: T::Hash) -> Gender {
match random_hash.as_ref()[0] % 2 {
0 => Gender::Male,
_ => Gender::Female,
}
}
fn new(dna: T::Hash, owner: T::AccountId) -> Self {
Kitty { dna, gender: Kitty::<T>::generate_gender(dna), owner, price: None }
}
}
In 4-onchain-randomness we will cover the Onchain Randomness topic which is used to generate the DNA for the Kitty. Then this DNA is used to generate the gender of the Kitty as well.
One last thing about the type object, you may notice there is MaxKittiesOwned
type declared in the Config
trait of the Pallet
. The purpose of this type is to tell the Runtime which bounded value can be passed in from the Runtime (Learn more from Substrate Docs - Configure Runtime Constants).
Question
βοΈ : Why don't we store theMaxKittiesOwned
withStorageValue
? Because we want to add boundary to the vector of kitties implemented later (below) with a constant which is not declared upfront.StorageValue
does not allow us to do it so we need to configure a constant on the runtime initialized.
#[pallet::config]
pub trait Config: frame_system::Config {
/// Other type declarations...
/// [2-data-structure]: The maximum amount of kitties a single account can own.
#[pallet::constant]
type MaxKittiesOwned: Get<u32>;
}
In the context of Substrate Kitties, we will need data structures that can provide:
Collection of Kitties
: We want a data structure that can helps to get the Kitty complete data by DNA whenever we need. Hence,StorageMap
is a suitable data structure for this. We don't want to useStorageValue
withVec
because this is expensive when we want to access the Kitty.
Twox64Concat
is a hashing technique that is used to hash the keys stored in theStorageMap
/// [2-data-structure]: Maps the kitty struct to the kitty DNA. (hint: using StorageMap)
#[pallet::storage]
#[pallet::getter(fn kitty_collection)]
pub type Kitties<T: Config> = StorageMap<_, Twox64Concat, T::Hash, Kitty<T>>;
Relationship between Kitty and its Owner
: This is aone-to-one
relationship that helps to identify who owns that Kitty. In this case, we needO(1)
data structure that can help to traverse the relationship betweenOwner
andKitty
quickly. Hence, we can useStorageMap
.
/// [2-data-structure]: Track the kitties owned by each account. (hint: using StorageMap)
#[pallet::storage]
#[pallet::getter(fn owner_of)]
pub(super) type KittyOwner<T: Config> =
StorageMap<_, Twox64Concat, T::Hash, Option<T::AccountId>, ValueQuery>;
Relationship between Owner and their Kitties
: This is aone-to-many
relationship that helps to identify Kitties owned by an Owner. In this case, we needO(1)
data structure that can help to traverse the relationship betweenOwner
and a list ofKitty
quickly. Hence, we can useStorageMap
withBoundedVec
to store a list of Kitty DNAs. Remember that any computation and memory space costs money, so we should useBounded
storage structure for memory efficiency.
/// [2-data-structure]: Keep track of kitties owned by the owner account
#[pallet::storage]
pub(super) type KittiesOwned<T: Config> = StorageMap<
_,
Twox64Concat,
T::AccountId,
BoundedVec<[u8; 16], T::MaxKittiesOwned>,
ValueQuery,
>;
Total number of Kitties minted
: We want to track the number of Kitties minted through our blockchain.
/// [2-data-structure]: Keeps track of the number of kitties in existence. (hint: using StorageValue)
#[pallet::storage]
#[pallet::getter(fn all_kitties_count)]
pub(super) type AllKittiesCount<T: Config> = StorageValue<_, u64, ValueQuery>;
When users interact with a blockchain they call dispatchable functions to do something. Because those functions are called from the outside of the blockchain interface, in Polkadot's terms any action that involves a dispatchable function is an Extrinsic.
#[pallet::call_index(0)]
#[pallet::weight(T::WeightInfo::dispatchable_function_name())]
pub fn dispatchable_function_name(origin: OriginFor<T>) -> DispatchResult
A function signature of a dispatchable function declared in the Pallet code must return a DispatchResult
and accept a first parameter is an origin typed OriginFor<T>
.
Events and errors are used to notify about specific activity. Please use this for debugging purpose only. Events and Errors should not be used as a communication method between functionalities.
In our codebase, we will declare these errors and events. The syntax is basically Rust code but with macro #[pallet::error]
// Errors inform users that something went wrong.
#[pallet::error]
pub enum Error<T> {
/// An account may only own `MaxKittiesOwned` kitties.
TooManyOwned,
/// This kitty already exists!
DuplicateKitty,
/// An overflow has occurred!
Overflow,
/// This kitty does not exist!
NoKitty,
/// You are not the owner of this kitty.
NotOwner,
/// Trying to transfer or buy a kitty from oneself.
TransferToSelf,
/// Ensures that the buying price is greater than the asking price.
BidPriceTooLow,
/// This kitty is not for sale.
NotForSale,
}
And comment out the Created
event so that we can deposit an event on new kitty minted.
#[pallet::event]
#[pallet::generate_deposit(pub(super) fn deposit_event)]
pub enum Event<T: Config> {
// A new kitty was successfully created.
Created { kitty: T::Hash, owner: T::AccountId },
}
To dispatch an event, we do
// deposit a new event when the kitty is created
Self::deposit_event(Event::Created { kitty: kitty_dna, owner: sender });
/// Create a new unique kitty.
#[pallet::call_index(0)]
#[pallet::weight(T::WeightInfo::create_kitty())]
pub fn create_kitty(origin: OriginFor<T>) -> DispatchResult {
// Ensure that sender did sign this extrinsic call
let sender = ensure_signed(origin)?;
// Generate a randome DNA (this will be guided in 4-onchain-randomness)
let kitty_dna = Pallet::<T>::gen_dna(&sender);
ensure!(!<Kitties<T>>::contains_key(kitty_dna), Error::<T>::DuplicateKitty);
// 1. map the new DNA with the struct data of Kitty
// ERROR: We throw an error if there exists a Kitty already
<Kitties<T>>::insert(kitty_dna, Kitty::<T>::new(kitty_dna, sender.clone()));
// 2. map the new DNA with its new owner
// ERROR: We throw an error if there exists a Kitty already
ensure!(!<KittyOwner<T>>::contains_key(kitty_dna), Error::<T>::DuplicateKitty);
<KittyOwner<T>>::insert(kitty_dna, Some(&sender));
// 3. update the total count of kitties
let new_all_kitties_count =
Self::all_kitties_count().checked_add(1).ok_or(Error::<T>::Overflow).unwrap();
<AllKittiesCount<T>>::put(new_all_kitties_count);
// 4. push the new kitty DNA to the list of existing kitties owned by a sender
KittiesOwned::<T>::try_append(&sender, kitty_dna)
// ERROR: We throw an error if there are too many Kitties owned by the sender
.map_err(|_| Error::<T>::TooManyOwned)?;
// EVENT: Deposit a new event when the kitty is created
Self::deposit_event(Event::Created { kitty: kitty_dna, owner: sender });
Ok(())
}
- Substrate Docs - Randomness
- Substrate How-to Guides - Incorporate Randomness
- Substrate Stack Exchange - Onchain Pseudo Random Numbers Agreed by All
Onchain randomness is quite important for a Turing complete applications. There are many use cases involved the randomness like gambling, probabilistic computation or random factors in gaming. But in blockchain, state in the state machine must be deterministic so we don't have a real randomness but pseudo randomness
.
To add randomness feature to our Kitty DNA generation logic, we will use pallet_insecure_randomness_collective_flip
pallet. This Pallet is not supposed to be used on production
so please be aware of it.
Logic behinds pallet_insecure_randomnes_collective_flip
The logic behinds this Pallet crate is simple. It has an offchain worker running to store a block hash to the onchain storage when block is initialized.
#[pallet::hooks]
impl<T: Config> Hooks<BlockNumberFor<T>> for Pallet<T> {
fn on_initialize(block_number: T::BlockNumber) -> Weight {
// take the parent hash of the block (block hash of the previous block)
let parent_hash = <frame_system::Pallet<T>>::parent_hash();
// store into the onchain storage
<RandomMaterial<T>>::mutate(|ref mut values| {
if values.try_push(parent_hash).is_err() {
let index = block_number_to_index::<T>(block_number);
values[index] = parent_hash;
}
});
T::DbWeight::get().reads_writes(1, 1)
}
}
After 81 block hashes, it takes all thoses and generate a random value based on it. In the Pallet source code, you can see it defines this constant value for the number of random materials.
const RANDOM_MATERIAL_LEN: u32 = 81;
Take a look at the seed generation code
let hash_series = <RandomMaterial<T>>::get();
let seed = if !hash_series.is_empty() {
// Always the case after block 1 is initialized.
hash_series
.iter()
.cycle()
.skip(index)
.take(RANDOM_MATERIAL_LEN as usize)
.enumerate()
.map(|(i, h)| (i as i8, subject, h).using_encoded(T::Hashing::hash))
.triplet_mix()
} else {
T::Hash::default()
};
As you see, it is quite straightforward and simple to understand. This randomness can be predicted in advanced but still random enough to not be predicted.
Ok so now how can we implement this randomness feature for our Kitty DNA generation code?
We will add the below KittyRandomness
type metadata into the Config
trait of the pallet. Hence, on the Runtime
side, we can add a Pallet that matches the type.
/// [4-onchain-randomness]: The type of Randomness we want to specify for this pallet.
type KittyRandomness: Randomness<Self::Hash, BlockNumberFor<Self>>;
On Runtime
side, we add the pallet_insecure_randomness_collective_flip
crate to the dependency list.
pallet-insecure-randomness-collective-flip = { git = "https://github.com/paritytech/substrate", package = "pallet-insecure-randomness-collective-flip", default-features = false, branch = "polkadot-v0.9.42" }
With this, we can define the Pallet on the Runtime
side. Inside construct_runtime!
macro, we add RandomnessCollectiveFlip
.
construct_runtime!(
pub struct Runtime
where
Block = Block,
NodeBlock = opaque::Block,
UncheckedExtrinsic = UncheckedExtrinsic,
{
/** Other pallet declarations **/
RandomnessCollectiveFlip: pallet_insecure_randomness_collective_flip, // Newly added randomness pallet
Kitties: pallet_substratekitties, // Our Kitties pallet
}
);
/** Other code... */
We need to make changes to the Pallet Kitties config code. It simply plugs and play.
+ impl pallet_insecure_randomness_collective_flip::Config for Runtime {}
impl pallet_substratekitties::Config for Runtime {
type RuntimeEvent = RuntimeEvent;
type WeightInfo = pallet_substratekitties::weights::SubstrateWeight<Runtime>;
type Currency = Balances;
type MaxKittiesOwned = frame_support::pallet_prelude::ConstU32<100>;
+ type KittyRandomness = RandomnessCollectiveFlip;
}
To check if Runtime code is functional, please run cargo check
.
Actually, I already add all these Randomness stuff in the
1-setup
but feel free to reimplement the feature yourself so you can fully grasp the idea of this tutorial
Now we are ready to generate DNA for our Kitty
// [4-onchain-randomness] Generates and returns DNA and Gender
fn gen_dna(minter: &T::AccountId) -> T::Hash {
let (output, block_number) = T::KittyRandomness::random(&b"dna"[..]);
// Experiment: You can experiment your self to have only block_number and minter as a parameters of the hashing function. This can be generated if you mint a new Kitty in different blocks. Otherwise, it will be double spending
let payload = (output, block_number, minter);
T::Hashing::hash_of(&payload)
}
That's enough for the Substrate part, now we have an API for create_kitty
ready, let's implement a frontend to interact with the logic defined in our Pallet code. Before starting the frontend, please follow these steps:
Frontend code implementation is complete already. Please follow the blog or clone
substrate-frontend-template
to work from scratch
cd frontend
npm run install
There are many abstractions on the frontend side as it uses multiple open-source libraries like @polkadot/api
. I will keep it in a decent level of abstraction so you can still understand how does it work.
You are supposed to have a decent knowledge of React and Javascript to grasp the idea of this tutorial step. Because we mainly use RPC methods from
@polkadot/api
, having a prior knowledge of frontend development is a must.
Let's break down API call to the Substrate Node. On the frontend code, you can log out the methods by doing
// Kitties.js
console.log(api.query.kitties);
Simply speaking, this API call abstract a way RPC call to the storage getter.
// Example of the code to fetch all Kitties
const asyncFetch = async () => {
unsub = await api.query.kitties.allKittiesCount(async (count) => {
// Fetch all kitty keys
const entries = await api.query.kitties.kitties.entries();
const kittiesMap = entries.map((entry) => {
return {
id: toHexString(entry[0].slice(-32)),
...parseKitty(entry[1].unwrap()),
};
});
setKitties(kittiesMap);
});
};
How about dispatching an extrinsic call to the Subtrate node backend? Please a full view of the TxButton.js
file to get the overall idea.
api.tx[palletRpc][callable];
This is what you actually see in that code file, in Kitties.js
<TxButton
label="Create Kitty"
type="SIGNED-TX"
setStatus={setStatus}
attrs={{
palletRpc: "kitties",
callable: "createKitty",
inputParams: [],
paramFields: [],
}}
/>
What it actually looks like is
api.tx.kitties.createKitty(...params);
Reflect this with what we did on the Substrate backend
// runtime/lib.rs
construct_runtime!(
pub struct Runtime
where
Block = Block,
NodeBlock = opaque::Block,
UncheckedExtrinsic = UncheckedExtrinsic,
{
// ...other pallet declaration
Kitties: pallet_substratekitties,
}
);
// substratekitties/lib.rs
#[pallet::call_index(0)]
#[pallet::weight(T::WeightInfo::create_kitty())]
pub fn create_kitty(origin: OriginFor<T>) -> DispatchResult
So it would be Kitties.create_kitty
which is converted to the frontend code after deserializing is api.tx.kitties.createKitty
.
That's it, it is quite simple about how to interact with your Pallet code right? Polkadot team definitely has many good engineers. There are a few other things like "How to get account in the network and its balance?"
function BalanceAnnotation(props) {
const { api, currentAccount } = useSubstrateState();
const [accountBalance, setAccountBalance] = useState(0);
// When account address changes, update subscriptions
useEffect(() => {
let unsubscribe;
// If the user has selected an address, create a new subscription
currentAccount &&
api.query.system
.account(acctAddr(currentAccount), (balance) =>
setAccountBalance(balance.data.free.toHuman())
)
.then((unsub) => (unsubscribe = unsub))
.catch(console.error);
return () => unsubscribe && unsubscribe();
}, [api, currentAccount]);
return currentAccount ? (
<Label pointing="left">
<Icon name="money" color="green" />
{accountBalance}
</Label>
) : null;
}
It simply get the data from the System
pallet about the accounts on the existing blockchain and their balances.
I guess that's enough for the overall idea of the frontend code. After retrieving the data from the Substrate node, the part of visualizing it is your thing.
Also, I might miss this. If you are interested into how the unique Kitty image is generated, please take a look at KittyAvatar.js
.
const dnaToAttributes = (dna) => {
const attribute = (index, type) =>
IMAGES[type][dna[index] % IMAGES[type].length];
return {
body: attribute(0, "body"),
eyes: attribute(1, "eyes"),
accessory: attribute(2, "accessory"),
fur: attribute(3, "fur"),
mouth: attribute(4, "mouth"),
};
};
Now follow what you learnt, and implement the logic for these methods below. Play with it both on Runtime and Frontend so you can understand the flow of Pallet development fully.
#[pallet::call_index(1)]
#[pallet::weight(T::WeightInfo::transfer())]
pub fn transfer(
origin: OriginFor<T>,
to: T::AccountId,
kitty_dna: T::Hash,
) -> DispatchResult
#[pallet::call_index(2)]
#[pallet::weight(T::WeightInfo::set_price())]
pub fn set_price(
origin: OriginFor<T>,
kitty_dna: T::Hash,
new_price: Option<BalanceOf<T>>,
) -> DispatchResult
#[pallet::call_index(5)]
#[pallet::weight(T::WeightInfo::buy_kitty())]
pub fn buy_kitty(
origin: OriginFor<T>,
kitty_dna: T::Hash,
bid_price: BalanceOf<T>,
) -> DispatchResult
Pallet NFT is a production-ready module used to build the logic for NFT collections on the Substrate blockchain. You can learn more about the NFT pallet in this wiki page: https://wiki.polkadot.network/docs/learn-nft-pallets
In this Substrate Kitties workshop tutorial, we want to add the Pallet NFT to the Substrate blockchain. Previous stages only hard-coded a struct Kitty
which is not an ideal design for an application-specific blockchain as we would expect our blockchain as a service to create their own NFT collections, not only Kitty
.
To add the Pallet NFT to our blockchain, we add this dependency line to the runtime Cargo.toml
pallet-nfts = { version = "4.0.0-dev", git = "https://github.com/paritytech/substrate.git", branch = "polkadot-v0.9.42", default-features = false }
Then we need to make a few changes to the Runtime lib.rs
code
parameter_types! {
pub NftsPalletFeatures : PalletFeatures = PalletFeatures::all_enabled();
pub const NftsMaxDeadlineDuration : BlockNumber = 12 * 30 * DAYS; // 12 months * 30 days
pub const NftsCollectionDeposit : Balance = 100;
pub const NftsItemDeposit : Balance = 100;
pub const MetadataDepositPerByte: Balance = 1 * DOLLARS;
pub const AssetDeposit: Balance = 100 * DOLLARS;
pub const ApprovalDeposit: Balance = 1 * DOLLARS;
pub const StringLimit: u32 = 50;
pub const MetadataDepositBase: Balance = 10 * DOLLARS;
}
impl pallet_nfts::Config for Runtime {
type RuntimeEvent = RuntimeEvent;
type CollectionId = u32;
type ItemId = u32;
type Currency = Balances;
type CreateOrigin = AsEnsureOriginWithArg<EnsureSigned<AccountId>>;
type ForceOrigin = EnsureRoot<AccountId>;
type Locker = ();
type CollectionDeposit = NftsCollectionDeposit;
type ItemDeposit = NftsItemDeposit;
type MetadataDepositBase = MetadataDepositBase;
type AttributeDepositBase = MetadataDepositBase;
type DepositPerByte = MetadataDepositPerByte;
type StringLimit = ConstU32<256>;
type KeyLimit = ConstU32<64>;
type ValueLimit = ConstU32<256>;
type ApprovalsLimit = ConstU32<6>;
type ItemAttributesApprovalsLimit = ConstU32<30>;
type MaxTips = ConstU32<10>;
type MaxDeadlineDuration = NftsMaxDeadlineDuration;
type MaxAttributesPerCall = ConstU32<10>;
type Features = NftsPalletFeatures;
type OffchainSignature = Signature;
type OffchainPublic = <Signature as Verify>::Signer;
type WeightInfo = pallet_nfts::weights::SubstrateWeight<Runtime>;
}
To submit a proposal, ideas, or any questions, please submit them here: OpenGuild Discussion π¬ View tickets and activities that you can contribute: Community Activities ποΈ
-
Help to grow the community: Community growth is a collective effort. By actively engaging with and inviting fellow enthusiasts to join our community, you play a crucial role in expanding our network. Encourage discussions, share valuable insights, and foster a welcoming environment for newcomers.
-
Participate in workshops and events: Be an active participant in our workshops and events. These sessions serve as valuable opportunities to learn, collaborate, and stay updated on the latest developments in the Polkadot ecosystem. Through participation, you not only enhance your knowledge but also contribute to the collaborative spirit of OpenGuild. Share your experiences, ask questions, and forge connections with like-minded individuals.
-
Propose project ideas: Your creativity and innovation are welcomed at OpenGuild. Propose project ideas that align with the goals of our community. Whether it's a new application, a tool, or a solution addressing a specific challenge in the Polkadot ecosystem, your ideas can spark exciting collaborations.
-
Contribute to our developer tools: Get involved in the ongoing development and improvement of tools that aid developers in their projects. Whether it's through code contributions, bug reports, or feature suggestions, your involvement in enhancing these tools strengthens the foundation for innovation within OpenGuild and the broader Polkadot community.
Open source projects like Substrate and this workshop could not be successful without the collective minds and collaborative effort of the development community.
The Substratekitties workshop stands on the backs of giants like Cryptokitties, Cryptozombies, Docsify, Monaco Editor, David Revoy's Cat Avatar Generator, and numerous volunteers to report errors and bugs along the way.
We hope this educational material teaches you something new, and in turn, you teach others too.