Frameless Template: Build Guide From Solochain

by Mei Lin 47 views

Hey guys! Today, we're diving deep into the heart of Substrate and learning how to build a frameless template from a Solochain setup. This is a super cool project that will help you understand Substrate at a much lower level and give you the power to customize your blockchain like never before. We're going to walk through each step, explaining the why behind the how, so you’ll not only be able to build it but also truly understand it.

Introduction: Embracing the Frameless World

In this comprehensive tutorial, we're going to explore the exciting world of frameless Substrate runtimes. The goal here is to transform a standard Solochain template into a frameless template by strategically removing FRAME dependencies and manually constructing the runtime. This process is not just about tweaking code; it’s about gaining a fundamental understanding of how Substrate works under the hood. Think of it like taking apart a car engine – you might get your hands dirty, but you’ll learn exactly how everything fits together. So, let's buckle up and get ready for this thrilling journey!

Why Go Frameless?

Before we dive into the nitty-gritty, let's chat about why you might want to go frameless in the first place. The standard Substrate FRAME (Framework for Runtime Aggregation of Modularized Entities) is fantastic for rapid development, providing a modular and high-level way to build blockchains. FRAME gives you all sorts of goodies out of the box, like storage management, dispatch logic, and even some pre-built modules (pallets) for common blockchain functions. It's like having a Lego set for building blockchains – super versatile and easy to use.

However, sometimes you need more control. FRAME, while powerful, can also add some overhead and impose certain constraints. When you go frameless, you're essentially building your runtime from scratch, picking and choosing each component and how it interacts. This gives you incredible flexibility and the ability to optimize your blockchain for specific use cases. Going frameless allows you to really fine-tune your blockchain, making it leaner, faster, and perfectly suited for your unique needs. For instance, you might want to build a runtime with a completely custom consensus mechanism or a specialized storage layout. Or maybe you're just a curious soul who wants to know how the sausage is made!

What We'll Cover

This tutorial is structured to be a step-by-step guide, starting from a basic Solochain template and transforming it into a frameless marvel. Here’s a sneak peek at what we'll be covering:

  • Setting the Stage: We’ll begin by examining the existing Solochain template, identifying the FRAME components, and preparing our development environment.
  • Deconstructing FRAME: Next, we’ll systematically remove FRAME dependencies, which involves deleting some code and tweaking others.
  • Manual Runtime Construction: The heart of the tutorial! We’ll manually build the runtime, adding essential components like the dispatcher, storage, and basic modules.
  • Implementing Core Functionality: We’ll implement the crucial functionalities that make a blockchain tick, such as transaction processing and state transitions.
  • Testing and Validation: Of course, no project is complete without testing. We’ll make sure our frameless runtime works as expected.
  • Optimization and Customization: Finally, we'll explore ways to optimize and customize our runtime to fit specific requirements.

By the end of this tutorial, you'll have a solid understanding of how to build a Substrate runtime from the ground up, without relying on FRAME. You’ll be equipped to tackle complex blockchain projects and truly understand the inner workings of Substrate.

Step 1: Setting the Stage – Examining the Solochain Template

Alright, let’s roll up our sleeves and get started! Our first step is to dive into the existing Solochain template and get a good lay of the land. Think of this as our initial reconnaissance mission. We need to understand what we’re starting with before we can start dismantling and rebuilding.

Understanding the Solochain Template

So, what exactly is a Solochain template? In the Substrate world, a Solochain is a simple, single-node blockchain that’s super handy for development and testing. It’s like the “Hello, World!” of blockchains. The Solochain template provides a basic but fully functional blockchain setup, complete with essential components like a genesis block, basic transaction handling, and a simple consensus mechanism. It's built using FRAME, which means it leverages pre-built modules (pallets) and a high-level API to make development easier.

If you’ve worked with Substrate before, you’re probably familiar with the typical project structure. If not, no worries! We'll break it down:

  • runtime/: This is where the magic happens. The runtime directory contains the core logic of your blockchain. It defines the state transition function – the rules that govern how the blockchain evolves as transactions are processed. This is where we’ll be spending most of our time.
  • pallets/: This directory houses the FRAME pallets. Pallets are like modules or plugins that provide specific functionalities, such as balance management, staking, or governance. The Solochain template typically includes a few basic pallets like System, Balances, and Timestamp.
  • node/: The node directory contains the code for the Substrate node – the actual executable that runs your blockchain. This includes things like the RPC interface, networking, and the client’s command-line interface.
  • src/: Inside each directory, you'll find Rust source code files (.rs files). Rust is the language of choice for Substrate development, known for its safety, performance, and suitability for blockchain applications.

Identifying FRAME Components

Our main goal is to remove FRAME dependencies, so we need to identify them first. Let's focus on the runtime/ directory, as that's where the bulk of the FRAME-related code resides. Here are some key FRAME components you’ll typically find in a Solochain runtime:

  • construct_runtime! Macro: This macro is the heart of FRAME runtime construction. It’s used to declare and configure the runtime, including specifying which pallets to include and how they interact. It’s like the master orchestrator of the runtime.
  • #[derive_impl(…)] Attributes: These attributes are used to automatically implement traits for the runtime, such as the frame_system::Config trait. They save you from writing a lot of boilerplate code but also tie you closely to the FRAME framework.
  • FRAME Pallets (e.g., frame_system, pallet_balances): These are the pre-built modules that provide essential functionalities. We'll need to replace their functionality with our own custom implementations.
  • FRAME Storage Items: FRAME provides a powerful storage abstraction, but we'll need to manage storage manually in our frameless runtime.

Preparing the Development Environment

Before we start hacking away at the code, let's make sure our development environment is set up and ready to go. Here’s what you’ll need:

  • Rust and Cargo: Rust is the language we’ll be using, and Cargo is Rust’s package manager and build tool. Make sure you have Rust installed and configured correctly. You can find instructions on how to do this on the official Rust website.
  • Substrate Dependencies: You'll need to have the Substrate dependencies installed, including the subkey tool and the Substrate CLI. The easiest way to do this is to follow the instructions in the Substrate documentation.
  • A Code Editor: Choose your favorite code editor! VS Code, IntelliJ IDEA, and Sublime Text are all popular choices.

Once you have your environment set up, clone the Solochain template repository from the Substrate Developer Hub. This will give you a working Solochain project to start with.

git clone https://github.com/substrate-developer-hub/substrate-node-template
cd substrate-node-template

Now, let’s open the project in your code editor and start exploring! Take some time to browse the runtime/src/lib.rs file, which is the main entry point for the runtime. You’ll see the construct_runtime! macro, the #[derive_impl(…)] attributes, and the various FRAME pallets being used. This is what we’ll be dismantling in the next step.

Step 2: Deconstructing FRAME – Removing the Dependencies

Okay, now for the fun part – tearing things down! In this step, we’re going to systematically remove the FRAME dependencies from our Solochain template. This might sound a little scary, but don’t worry, we’ll take it one step at a time. Remember, the goal is to replace the FRAME components with our own custom implementations.

Removing the construct_runtime! Macro

Our first target is the construct_runtime! macro. As we discussed earlier, this macro is the central orchestrator of the FRAME runtime. It defines the runtime struct, configures the pallets, and sets up the dispatch logic. In a frameless runtime, we’ll be handling all of this manually, so we need to say goodbye to the macro.

Open the runtime/src/lib.rs file in your code editor. You’ll see something like this:

construct_runtime! {
 pub enum Runtime where
  Block = Block,
  NodeBlock = Block,
  UncheckedExtrinsic = UncheckedExtrinsic
 {
  System: frame_system::{Pallet, Call, Config, Storage, Event<T>},
  RandomnessCollectiveFlip: pallet_randomness_collective_flip::{Pallet, Storage},
  Timestamp: pallet_timestamp::{Pallet, Call, Storage, Inherent},
  Aura: pallet_aura::{Pallet, Config<T>, Storage, Event<T>},
  Grandpa: pallet_grandpa::{Pallet, Call, Storage, Config, Event},
  Balances: pallet_balances::{Pallet, Call, Storage, Config<T>, Event<T>},
  TransactionPayment: pallet_transaction_payment::{Pallet, Storage, Event<T>},
  Sudo: pallet_sudo::{Pallet, Call, Config<T>, Storage, Event<T>},
  TemplatePallet: pallet_template::{Pallet, Call, Storage, Event<T>},
 }
}

Simply delete the entire construct_runtime! block. Yes, it’s that simple! This will remove the FRAME-generated runtime definition and pave the way for our manual construction.

Removing #[derive_impl(…)] Attributes

Next up are the #[derive_impl(…)] attributes. These attributes, as you recall, automatically implement traits for our runtime. While convenient, they also create a dependency on FRAME. We need to implement these traits manually to achieve our frameless goal.

You’ll find these attributes above the implementation blocks for the runtime configuration traits. For example:

#[derive_impl(frame_system::config_preludes::TestDefaultConfig as frame_system::DefaultConfig)]
impl frame_system::Config for Runtime {
 // ...
}

Remove all instances of #[derive_impl(…)] attributes. Don't worry about the compiler errors that will pop up – we’ll address them in the next step when we manually implement the traits.

Removing FRAME Pallet Dependencies

Now, let’s tackle the FRAME pallets. Our Solochain runtime likely includes several pallets, such as frame_system, pallet_balances, pallet_timestamp, and others. We need to remove these dependencies and replace their functionality with our own code.

This involves several steps:

  1. Remove Pallet Imports: At the top of runtime/src/lib.rs, you’ll see a list of use statements that import the FRAME pallets. Remove these lines.

    use frame_system as system;
    use pallet_balances as balances;
    use pallet_timestamp as timestamp;
    // ...
    
  2. Remove Pallet Instances: Inside the runtime definition (where the construct_runtime! macro used to be), you’ll see instances of the pallets. These are now gone since we deleted the macro, but it's worth noting where they were.

  3. Remove Pallet Configurations: You'll find configuration traits and implementations for each pallet. These need to be removed as well. For example:

    impl pallet_balances::Config for Runtime {
     // ...
    }
    

    Delete these impl blocks.

Cleaning Up Genesis Configuration

The genesis configuration defines the initial state of the blockchain. In a FRAME-based runtime, this is often handled by the pallets themselves. Since we’re going frameless, we need to handle genesis configuration manually.

Look for the GenesisConfig struct and its implementation. You’ll likely find code that uses FRAME pallets to set up the initial state. Remove this code. We’ll replace it with our own genesis initialization logic later.

Handling Compiler Errors (For Now)

At this point, your code will likely be riddled with compiler errors. That’s perfectly normal! We’ve removed a lot of code and haven’t replaced it yet. Don’t panic! We’ll address these errors in the next step when we start building our frameless runtime.

The key thing is that we’ve successfully deconstructed the FRAME-based runtime. We’ve removed the construct_runtime! macro, the #[derive_impl(…)] attributes, the FRAME pallet dependencies, and the FRAME-based genesis configuration. We’ve cleared the canvas, and now we’re ready to start painting our masterpiece!

Step 3: Manual Runtime Construction – Building the Core

Alright, guys, we've deconstructed the FRAME-based runtime, and now it’s time to get our hands dirty and rebuild it from the ground up! This is where we’ll manually construct the runtime, adding the essential components that make a blockchain tick. Think of this as laying the foundation and building the core infrastructure of our frameless blockchain.

Defining the Runtime Struct

The first thing we need to do is define our runtime struct. This struct will hold the state of our blockchain and serve as the central hub for our runtime logic. In a FRAME-based runtime, this was handled by the construct_runtime! macro. Now, we’re in charge.

In runtime/src/lib.rs, define a struct named Runtime. It doesn't need to contain any fields just yet; we'll add those as we build out the functionality.

#[derive(Debug, PartialEq, Eq)]
pub struct Runtime;

Implementing Core Traits

Next, we need to implement some core traits that define the behavior of our runtime. These traits were previously implemented automatically by the #[derive_impl(…)] attributes, but now we'll do it manually.

frame_system::Config

The frame_system::Config trait is the most fundamental trait for a Substrate runtime. It defines the configuration parameters for the System pallet (or, in our case, our custom system logic). This includes things like the block number type, the hashing algorithm, and the account identifier type.

We'll need to implement this trait for our Runtime struct. Let’s start by adding the necessary type definitions. Note that since we are building a frameless runtime, we won't be using the frame_system pallet directly. Instead, we'll define our own types and constants.

use sp_runtime::{
 generic,
 traits::{BlakeTwo256, IdentityLookup},
 MultiSignature,
}; 
use sp_core::H256;


/// Block type as expected by this runtime.
pub type Block = generic::Block<Header, UncheckedExtrinsic>;
/// Block header type as expected by this runtime.
pub type Header = generic::Header<BlockNumber, BlakeTwo256>;
/// Unchecked extrinsic type as expected by this runtime.
pub type UncheckedExtrinsic = generic::UncheckedExtrinsic<Address, Call, Signature, SignedExtra>;
/// Extrinsic type that has already been checked.
pub type BlockNumber = u32;
/// Balance type to be used in this runtime.
pub type Balance = u128;
/// Index type for storing non-fungible or unique identifiers.
pub type Index = u32;
/// A hash of some crypto-sensitive data. 
pub type Hash = H256;
/// The signature type used by accounts/transactions.
pub type Signature = MultiSignature;
/// The address format for describing accounts. 
pub type Address = sp_runtime::MultiAddress<AccountId, AccountIndex>;
/// AccountId32 is the standard means of addressing accounts. 
pub type AccountId = <Signature as sp_runtime::traits::Verify>::Signer;
/// Lookup the constituent identity of an account ID. 
pub type AccountIndex = u32;
/// The type which is used to represent the kinds of upgrade that can be done to the runtime.
pub type RuntimeVersion = sp_version::RuntimeVersion;

impl frame_system::Config for Runtime {
 type BaseCallFilter = frame_support::traits::Everything;
 type BlockWeights = ();
 type BlockLength = ();
 type DbWeight = ();
 type RuntimeOrigin = RuntimeOrigin;
 type RuntimeCall = RuntimeCall;
 type Index = Index;
 type BlockNumber = BlockNumber;
 type Hash = Hash;
 type Hashing = BlakeTwo256;
 type AccountId = AccountId;
 type Lookup = IdentityLookup<Self::AccountId>;
 type Header = Header;
 type RuntimeEvent = RuntimeEvent;
 type BlockHashCount = BlockHashCount;
 type Version = RuntimeVersion;
 type PalletInfo = PalletInfo;
 type AccountData = AccountData<Balance>;
 type OnNewAccount = ();
 type OnKilledAccount = ();
 type SystemWeightInfo = ();
 type SS58Prefix = SS58Prefix;
 type OnSetCode = ();
 type MaxConsumers = frame_support::traits::ConstU32<16>;
}

Replace the () on the types that are not found in the code. We've defined some basic types like BlockNumber, Hash, and AccountId, which are essential for our blockchain. We've also specified the hashing algorithm (BlakeTwo256) and the account identifier lookup (IdentityLookup).

Other Configuration Traits

Depending on the functionalities you want to include in your frameless runtime, you might need to implement other configuration traits. For example, if you want to manage balances, you might need to define a BalancesConfig trait and implement it for your Runtime struct. We’ll explore this further in later steps.

Defining Runtime Events and Origin

Events are a crucial part of any blockchain runtime. They provide a way to track state changes and communicate information to the outside world. We need to define an enum that lists the possible events in our runtime.

#[derive(Debug, Encode, Decode, TypeInfo, MaxEncodedLen)]
pub enum RuntimeEvent {
 // Add your custom events here
}

Similarly, we need to define a RuntimeOrigin enum to represent the origin of a call – who or what initiated the call. This is important for access control and authorization.

#[derive(Debug, Encode, Decode, TypeInfo, MaxEncodedLen, Eq, PartialEq, Clone, Copy)]
pub enum RuntimeOrigin {
 // Add your custom origins here
}

For now, these enums are empty. We’ll add variants to them as we implement specific functionalities.

Setting Up Genesis Block

The genesis block is the first block in the blockchain, the foundation upon which everything else is built. We need to define the genesis state of our runtime, which includes things like initial account balances and other setup parameters.

In a FRAME-based runtime, genesis configuration is often handled by the pallets. In our frameless runtime, we’ll need to do it manually. This typically involves creating a GenesisConfig struct and providing a function to initialize the runtime state from this config.

We'll revisit genesis configuration in more detail when we implement specific modules and functionalities. For now, let's just create a placeholder struct and a basic initialization function.

pub struct GenesisConfig {
 // Add your genesis configuration parameters here
}

impl GenesisConfig {
 pub fn build(&self) -> sp_runtime::GenesisBuild<Runtime> {
  todo!()
 }
}

Creating a Basic Dispatcher

The dispatcher is the heart of the runtime. It’s responsible for receiving calls (transactions) and routing them to the appropriate functions for processing. In a FRAME-based runtime, this is handled by the construct_runtime! macro. In our frameless runtime, we need to build a dispatcher from scratch.

Let's start by defining a RuntimeCall enum. This enum will list the possible calls that our runtime can handle. For now, let’s keep it simple and just include a placeholder call.

#[derive(Debug, Encode, Decode, TypeInfo, PartialEq, Eq, Clone)]
pub enum RuntimeCall {
 // Add your custom calls here
}

Next, we need to create a function that takes a RuntimeCall and dispatches it to the appropriate handler. This function will be the core of our dispatcher.

impl Runtime {
 pub fn dispatch(call: RuntimeCall) -> Result<(), &'static str> {
  // Add your dispatch logic here
  Err(