Skip to content

Building a Minimal Node

This guide walks through the code needed to assemble a working Soil blockchain node. We will build three things: a runtime, a service layer, and a CLI entry point.

Overview

A Soil node is composed of three pieces:

  1. Runtime — defines the on-chain logic (which pallets are included, how they are configured).
  2. Service — wires up the client, database, networking, consensus, transaction pool, and RPC.
  3. CLI — parses command-line arguments and starts the node.
graph LR
    CLI --> Service
    Service --> Runtime
    Service --> Networking
    Service --> Consensus
    Service --> RPC
    Service --> TxPool[Transaction Pool]

Step 1: Define the Runtime

The runtime is the heart of your chain. It declares which pallets are included and how they are configured. Here is a minimal runtime with the System pallet (required) and Balances:

Types

use subsoil::runtime::generic;
use subsoil::core::sr25519;

/// The block number type.
pub type BlockNumber = u32;

/// An account's balance.
pub type Balance = u128;

/// A signature type.
pub type Signature = sr25519::Signature;

/// The account identifier (derived from the signature's public key).
pub type AccountId = <<Signature as subsoil::runtime::traits::Verify>::Signer
    as subsoil::runtime::traits::IdentifyAccount>::AccountId;

/// The block header.
pub type Header = generic::Header<BlockNumber, subsoil::runtime::traits::BlakeTwo256>;

/// The block type.
pub type Block = generic::Block<Header, UncheckedExtrinsic>;

/// A transaction with signature and dispatch info.
pub type UncheckedExtrinsic = generic::UncheckedExtrinsic<
    subsoil::core::sr25519::Public,
    RuntimeCall,
    Signature,
    TxExtension,
>;

/// Transaction extensions applied to every extrinsic.
pub type TxExtension = (
    topsoil_core::system::CheckNonce<Runtime>,
    topsoil_core::system::CheckWeight<Runtime>,
);

/// The executive, which orchestrates block execution.
pub type Executive = topsoil_executive::Executive<
    Runtime,
    Block,
    topsoil_core::system::ChainContext<Runtime>,
    Runtime,
    AllPalletsWithSystem,
>;

Pallet Configuration

Each pallet requires an impl block that satisfies its Config trait:

use topsoil::prelude::*;

parameter_types! {
    pub const BlockHashCount: BlockNumber = 256;
    pub RuntimeBlockWeights: topsoil_core::system::limits::BlockWeights
        = topsoil_core::system::limits::BlockWeights::simple_max(
            topsoil::weights::Weight::from_parts(1_000_000_000, u64::MAX),
        );
}

impl topsoil_core::system::pallet::Config for Runtime {
    type Block = Block;
    type BlockWeights = RuntimeBlockWeights;
    type BlockHashCount = BlockHashCount;
    type AccountId = AccountId;
    type Nonce = u32;
    type RuntimeEvent = RuntimeEvent;
    type RuntimeCall = RuntimeCall;
    type RuntimeOrigin = RuntimeOrigin;
    type PalletInfo = PalletInfo;
    // ... additional associated types
}

parameter_types! {
    pub const ExistentialDeposit: Balance = 1;
}

impl plant_balances::Config for Runtime {
    type Balance = Balance;
    type RuntimeEvent = RuntimeEvent;
    type ExistentialDeposit = ExistentialDeposit;
    type AccountStore = System;
    // ... additional associated types
}

Assembling the Runtime

The construct_runtime! macro brings everything together:

construct_runtime! {
    pub enum Runtime {
        System: topsoil_core::system,
        Balances: plant_balances,
    }
}

Runtime APIs

The client communicates with the runtime through a set of well-known APIs. At minimum, you must implement Core and BlockBuilder:

impl_runtime_apis! {
    impl subsoil::api::Core<Block> for Runtime {
        fn version() -> subsoil::runtime::RuntimeVersion {
            VERSION
        }
        fn execute_block(block: Block) {
            Executive::execute_block(block);
        }
        fn initialize_block(header: &<Block as subsoil::runtime::traits::Block>::Header)
            -> subsoil::runtime::ExtrinsicInclusionMode
        {
            Executive::initialize_block(header)
        }
    }

    impl subsoil::block_builder::BlockBuilder<Block> for Runtime {
        fn apply_extrinsic(
            extrinsic: <Block as subsoil::runtime::traits::Block>::Extrinsic,
        ) -> subsoil::runtime::ApplyExtrinsicResult {
            Executive::apply_extrinsic(extrinsic)
        }
        fn finalize_block() -> <Block as subsoil::runtime::traits::Block>::Header {
            Executive::finalize_block()
        }
        fn inherent_extrinsics(
            _data: subsoil::runtime::InherentData,
        ) -> Vec<<Block as subsoil::runtime::traits::Block>::Extrinsic> {
            vec![]
        }
        fn check_inherents(
            _block: Block,
            _data: subsoil::runtime::InherentData,
        ) -> subsoil::runtime::CheckInherentsResult {
            subsoil::runtime::CheckInherentsResult::new()
        }
    }
}

Step 2: Build the Service

The service layer creates the client, database backend, and consensus engine, then wires them together.

Partial Components

The first stage constructs the pieces that other stages depend on:

use soil_service::{self, PartialComponents};
use std::sync::Arc;

pub fn new_partial(
    config: &soil_service::Configuration,
) -> Result<
    PartialComponents<
        FullClient, FullBackend, FullSelectChain,
        soil_consensus::DefaultImportQueue,
        soil_txpool::TransactionPoolHandle<Block, FullClient>,
        (),
    >,
    soil_service::Error,
> {
    // Create the Wasm executor.
    let executor = soil_service::new_wasm_executor(&config.executor);

    // Create the client, backend, keystore, and task manager.
    let (client, backend, keystore_container, task_manager) =
        soil_service::new_full_parts::<Block, RuntimeApi, _>(
            config,
            None,  // telemetry handle
            executor,
            vec![], // pruning filters
        )?;
    let client = Arc::new(client);

    // Chain selection rule: longest chain.
    let select_chain = soil_consensus::LongestChain::new(backend.clone());

    // Transaction pool.
    let transaction_pool = Arc::from(
        soil_txpool::Builder::new(
            task_manager.spawn_essential_handle(),
            client.clone(),
            config.role.is_authority().into(),
        )
        .with_options(config.transaction_pool.clone())
        .build(),
    );

    // Import queue (manual seal for this example — simplest consensus).
    let import_queue = soil_manual_seal::import_queue(
        Box::new(client.clone()),
        &task_manager.spawn_essential_handle(),
        config.prometheus_registry(),
    );

    Ok(PartialComponents {
        client,
        backend,
        task_manager,
        keystore_container,
        select_chain,
        import_queue,
        transaction_pool,
        other: (),
    })
}

Full Node

The second stage starts networking, RPC, and consensus:

pub fn new_full(
    config: soil_service::Configuration,
) -> Result<soil_service::TaskManager, soil_service::Error> {
    let PartialComponents {
        client,
        backend,
        mut task_manager,
        keystore_container,
        select_chain,
        import_queue,
        transaction_pool,
        ..
    } = new_partial(&config)?;

    // Build the network.
    let net_config = soil_network::config::FullNetworkConfiguration::<
        Block,
        <Block as subsoil::runtime::traits::Block>::Hash,
        soil_network::NetworkWorker<Block, <Block as subsoil::runtime::traits::Block>::Hash>,
    >::new(&config.network, config.prometheus_registry().cloned());

    let (network, system_rpc_tx, tx_handler_controller, sync_service) =
        soil_service::build_network(soil_service::BuildNetworkParams {
            config: &config,
            net_config,
            client: client.clone(),
            transaction_pool: transaction_pool.clone(),
            spawn_handle: task_manager.spawn_handle(),
            spawn_essential_handle: task_manager.spawn_essential_handle(),
            import_queue,
            ..Default::default()
        })?;

    // Spawn background tasks (RPC, transaction handler, etc.).
    let _rpc_handlers = soil_service::spawn_tasks(soil_service::SpawnTasksParams {
        config,
        backend,
        client: client.clone(),
        keystore: keystore_container.keystore(),
        network: network.clone(),
        rpc_builder: Box::new(|_| Ok(jsonrpsee::RpcModule::new(()))),
        transaction_pool: transaction_pool.clone(),
        task_manager: &mut task_manager,
        system_rpc_tx,
        tx_handler_controller,
        sync_service,
        telemetry: None,
        ..Default::default()
    })?;

    // Start manual-seal block production (instant sealing for dev chains).
    let proposer = soil_service::basic_authorship::ProposerFactory::new(
        task_manager.spawn_handle(),
        client.clone(),
        transaction_pool.clone(),
        config.prometheus_registry(),
        None, // telemetry
    );

    soil_manual_seal::run_instant_seal(soil_manual_seal::InstantSealParams {
        block_import: client.clone(),
        env: proposer,
        client,
        pool: transaction_pool,
        select_chain,
        consensus_data_provider: None,
        create_inherent_data_providers: |_, _| async { Ok(()) },
    })?;

    Ok(task_manager)
}

Step 3: Create the CLI

The CLI parses arguments and starts the node:

use clap::Parser;
use soil_cli::SubstrateCli;

#[derive(Debug, Parser)]
pub struct Cli {
    #[command(subcommand)]
    pub subcommand: Option<Subcommand>,

    #[clap(flatten)]
    pub run: soil_cli::RunCmd,
}

impl SubstrateCli for Cli {
    fn impl_name() -> String {
        "My Soil Node".into()
    }

    fn impl_version() -> String {
        env!("CARGO_PKG_VERSION").into()
    }

    fn description() -> String {
        "A minimal blockchain node built with Soil".into()
    }

    fn author() -> String {
        env!("CARGO_PKG_AUTHORS").into()
    }

    fn support_url() -> String {
        "https://github.com/soil-rs/soil/issues".into()
    }

    fn copyright_start_year() -> i32 {
        2026
    }

    fn load_spec(
        &self,
        id: &str,
    ) -> Result<Box<dyn soil_chain_spec::ChainSpec>, String> {
        // Load a chain specification from a JSON file or use a built-in preset.
        match id {
            "dev" => Ok(Box::new(chain_spec::development_config())),
            path => Ok(Box::new(
                chain_spec::ChainSpec::from_json_file(std::path::PathBuf::from(path))
                    .map_err(|e| e.to_string())?,
            )),
        }
    }
}

fn main() -> soil_cli::Result<()> {
    let cli = Cli::from_args();

    let runner = cli.create_runner(&cli.run)?;
    runner.run_node_until_exit(|config| async move {
        service::new_full(config).map_err(soil_cli::Error::Service)
    })
}

Cargo.toml

A minimal Cargo.toml for the node binary:

[package]
name = "my-soil-node"
version = "0.1.0"
edition = "2021"

[dependencies]
clap = { version = "4", features = ["derive"] }
jsonrpsee = { version = "0.24" }

# Soil core
subsoil = { git = "https://github.com/soil-rs/soil.git" }
soil-cli = { git = "https://github.com/soil-rs/soil.git" }
soil-client = { git = "https://github.com/soil-rs/soil.git" }
soil-consensus = { git = "https://github.com/soil-rs/soil.git" }
soil-network = { git = "https://github.com/soil-rs/soil.git" }
soil-service = { git = "https://github.com/soil-rs/soil.git" }
soil-txpool = { git = "https://github.com/soil-rs/soil.git" }
soil-manual-seal = { git = "https://github.com/soil-rs/soil.git" }
soil-chain-spec = { git = "https://github.com/soil-rs/soil.git" }

# Runtime framework
topsoil = { git = "https://github.com/soil-rs/soil.git" }
topsoil-core = { git = "https://github.com/soil-rs/soil.git" }
topsoil-executive = { git = "https://github.com/soil-rs/soil.git" }

# Pallets
plant-balances = { git = "https://github.com/soil-rs/soil.git" }

What's Next

This example uses manual seal (instant block production) for simplicity. For a production chain you would replace it with a real consensus mechanism:

  • Aura (soil-aura, plant-aura) — round-robin block production, simple to set up.
  • BABE (soil-babe, plant-babe) — slot-based block production with VRF, used by Polkadot.
  • GRANDPA (soil-grandpa, plant-grandpa) — GHOST-based finality gadget, typically paired with BABE or Aura.
  • Proof of Work (soil-pow) — traditional mining-based consensus.

See the staging test node in contrib/soil-test-staging-node-cli/ for a complete example using BABE + GRANDPA with all the production features enabled.