Skip to content

Latest commit

 

History

History
251 lines (170 loc) · 18.6 KB

File metadata and controls

251 lines (170 loc) · 18.6 KB

Lesson 1 — A solochain that breathes: AURA + GRANDPA

Commit: f2fecbbStart the chain: a minimal AURA + GRANDPA solochain

Reading time: ~12 min.

Goal

By the end of this lesson, you have a working Substrate node — a single binary called substrate-tutorial — that produces blocks every 6 seconds, finalizes them through GRANDPA, holds an account-balance model, and charges fees on transactions. You can run it as a single-node dev chain (substrate-tutorial --dev) or as a two-node local testnet (substrate-tutorial --chain local). It has 6 pallets, no sudo, no governance, no staking, no smart contracts. It is deliberately the smallest viable substrate chain.

This is the foundation everything else gets layered on. The choices in this commit are the choices that constrain every later commit. Pay attention to the ones marked trade-off.

Background

A Substrate chain is two pieces of software wearing one binary:

  • The runtime — a no_std Rust crate that compiles to WebAssembly. This is the on-chain "smart contract" that defines all state transitions. Every node executes the same Wasm runtime; that's how they agree on what a block does.
  • The node (or "client") — a native Rust binary that imports the runtime as a Wasm blob, runs networking, manages the database, exposes JSON-RPC, and hosts the consensus algorithms.

A "pallet" is a FRAME module — a self-contained piece of runtime logic. The runtime is built by composing pallets. Each pallet exports a Config trait that the runtime fills in with concrete types.

Two problems every blockchain must solve:

  1. Block production — who gets to author the next block? Substrate ships several answers: AURA (round-robin), BABE (VRF-based slot lottery), PoW (Nakamoto-style mining), manual seal (test-only).
  2. Finality — when is a block irreversible? In Nakamoto consensus the answer is probabilistic ("6 confirmations"). Substrate decouples this: block production picks a block, and a separate algorithm — GRANDPA — explicitly votes on which chain is final.

This separation is unusual. Bitcoin and Ethereum 1.x conflate the two: the chain with the most work is both the production winner and the finality winner. Substrate keeps them separate so you can swap each independently. AURA + GRANDPA is one combination; BABE + GRANDPA is another; PoW + GRANDPA is technically possible but unusual.

If you've used Ethereum 2 / consensus layer, the analogy is roughly: AURA ≈ "scheduled proposers", BABE ≈ "RANDAO + proposer selection", GRANDPA ≈ "Casper FFG", though all three are different in mechanics.

The path we chose

AURA for block production, GRANDPA for finality. Both ship in polkadot-sdk's templates/solochain as the reference minimal setup. AURA is the simplest viable block producer that still gives you authority rotation — validators take turns in a round-robin order, each producing one block per slot. It needs no randomness, no slashing, no economic security. It is essentially "scheduled PoA" — a fixed authority set, fixed rotation.

GRANDPA is unusual among finality gadgets: it votes on chains rather than individual blocks, which means it can finalize many blocks in one round. It gives you actual finality — once GRANDPA finalizes a block, no validity-honoring node will ever revert it. This matters because it lets us not worry about reorgs at the application layer.

The choice of polkadot-sdk branch stable2603 is more practical: it's the current stable release at the time of writing, and it pins us to a known-good combination of pallet versions, runtime APIs, and toolchain. We use it as a git dependency with branch = "stable2603" rather than a crates.io version because the SDK lives as a single monorepo workspace and many of its crates are not published to crates.io.

We deliberately omit pallet-sudo. This is uncommon for a starter chain. We omit it so the rest of the tutorial is forced to confront the governance question honestly: without sudo, the chain has no admin authority, and that constrains every later decision (treasury, staking, runtime upgrades). The default substrate template includes sudo "for now"; we don't, so "for now" never quietly becomes "forever."

Code walkthrough

Workspace and crate structure

substrate-tutorial/
├── Cargo.toml            workspace + dep pins
├── Cargo.lock            committed (reproducible builds)
├── rust-toolchain.toml   Rust 1.93.0
├── .rustfmt.toml         copied from polkadot-sdk
├── LICENSE               GPL-3.0-only
├── README.md             this overview
├── node/                 the `substrate-tutorial` binary
└── runtime/              the Wasm runtime

The top-level Cargo.toml is a workspace manifest. It does three jobs:

  1. Declares the two member crates, node and runtime.
  2. Pins every polkadot-sdk dependency to one branch, so the two member crates always see the same SDK versions. This single source of truth is the whole reason workspace [workspace.dependencies] exists.
  3. Mirrors polkadot-sdk's lint configuration, so clippy doesn't fight you over the substrate ecosystem's conventions (specifically: clippy::all = "allow" umbrella with correctness and complexity opted back in).

The runtime is #![cfg_attr(not(feature = "std"), no_std)] because the Wasm blob must not link std. The std feature exists for tests and for the build.rs script that produces the Wasm.

The runtime exposes its compiled Wasm via:

#[cfg(feature = "std")]
include!(concat!(env!("OUT_DIR"), "/wasm_binary.rs"));

This is auto-generated by substrate-wasm-builder in runtime/build.rs. The node binary uses WASM_BINARY to embed the runtime at compile time.

The runtime's three sub-modules

// runtime/src/lib.rs
pub mod apis;
pub mod configs;
pub mod genesis_config_presets;
  • configs.rs — every impl Config for Runtime block, one per pallet.
  • apis.rs — every impl_runtime_apis! block — the Wasm functions the node calls into.
  • genesis_config_presets.rs — the dev and local-testnet genesis JSON patches.

This split is conventional and matches templates/solochain. You'll see all three grow as we add pallets.

The pallet declaration

// runtime/src/lib.rs
#[frame_support::runtime]
mod runtime {
    #[runtime::pallet_index(0)] pub type System = frame_system;
    #[runtime::pallet_index(1)] pub type Timestamp = pallet_timestamp;
    #[runtime::pallet_index(2)] pub type Aura = pallet_aura;
    #[runtime::pallet_index(3)] pub type Grandpa = pallet_grandpa;
    #[runtime::pallet_index(4)] pub type Balances = pallet_balances;
    #[runtime::pallet_index(5)] pub type TransactionPayment = pallet_transaction_payment;
}

Six pallets. The pallet_index numbers are bytes used to identify each pallet in encoded extrinsics, events, and metadata — they are part of the wire format. Changing them after launch is a breaking change for everything off-chain that decodes runtime data. Pre-launch (us), they're free.

Pallet declaration order also determines hook execution order: on_initialize runs in declaration order, on_finalize in reverse. Here the order doesn't matter much because no pallet's hook reads another's storage. Later it will.

The session-key declaration

impl_opaque_keys! {
    pub struct SessionKeys {
        pub aura: Aura,
        pub grandpa: Grandpa,
    }
}

Each validator has two session keys: an aura key (sr25519) for signing slot claims, and a grandpa key (ed25519) for signing finality votes. They're "session keys" because in future commits they'll be rotatable per session via pallet-session. Right now there's no pallet-session yet, so they're set once at genesis and never change.

Pallet configs

The six impl Config for Runtime blocks are in runtime/src/configs.rs. The two interesting ones for this lesson:

impl pallet_aura::Config for Runtime {
    type AuthorityId = AuraId;
    type DisabledValidators = ();
    type MaxAuthorities = ConstU32<32>;
    type AllowMultipleBlocksPerSlot = ConstBool<false>;
    type SlotDuration = pallet_aura::MinimumPeriodTimesTwo<Runtime>;
}

impl pallet_grandpa::Config for Runtime {
    type RuntimeEvent = RuntimeEvent;
    type WeightInfo = ();
    type MaxAuthorities = ConstU32<32>;
    type MaxNominators = ConstU32<0>;
    type MaxSetIdSessionEntries = ConstU64<0>;
    type KeyOwnerProof = sp_core::Void;
    type EquivocationReportSystem = ();
}

Three things to notice:

  1. MaxAuthorities = ConstU32<32> caps the validator set at 32. This is a fixed limit; growing past it later requires a runtime upgrade.
  2. KeyOwnerProof = sp_core::Void and EquivocationReportSystem = () — both pallets can support equivocation reporting (validators caught signing two conflicting blocks at the same slot get slashed). We stub them out because we have no slashing mechanism yet. A validator caught double-signing here would be reported but no consequence would attach. We'll fix this in lesson 3.
  3. MaxNominators = ConstU32<0> in grandpa — no nominators are possible, which is correct because we have no staking.

Genesis presets

// runtime/src/genesis_config_presets.rs (simplified)
fn testnet_genesis(
    initial_authorities: Vec<(AuraId, GrandpaId)>,
    endowed_accounts: Vec<AccountId>,
) -> Value {
    build_struct_json_patch!(RuntimeGenesisConfig {
        balances: BalancesConfig { balances: endowed_accounts.iter()...collect() },
        aura: pallet_aura::GenesisConfig { authorities: ... },
        grandpa: pallet_grandpa::GenesisConfig { authorities: ... },
    })
}

Two presets, development_config_genesis and local_config_genesis, exposed through sp_genesis_builder::GenesisBuilder runtime API. The node calls into the runtime to fetch them; chain spec files reference them by name (DEV_RUNTIME_PRESET, LOCAL_TESTNET_RUNTIME_PRESET).

This is the modern genesis pattern. Older substrate chains constructed GenesisConfig in the node's chain_spec.rs and the node had to know the runtime types. With GenesisBuilder, the runtime owns its genesis and any tool with the Wasm blob can build a working chain spec without the node binary.

The node

The node crate is bin = "substrate-tutorial" and stitches together: clap CLI, sc-cli command dispatch, chain spec loader, service builder, RPC layer. The interesting file is node/src/service.rs (~330 lines), which wires:

sc_consensus_grandpa::block_import   →   sc_consensus_aura::import_queue
sc_consensus_aura::start_aura        ← authoring worker
sc_consensus_grandpa::run_grandpa_voter  ← finality worker

GRANDPA wraps AURA's block import — every block AURA verifies for import passes through GRANDPA's justification import path. The two workers run concurrently. Block production runs at AURA's slot beat; finality lags by some amount and progresses asynchronously.

Other paths we could have taken

This is where it gets interesting. Every choice in this commit had alternatives. Honest treatment of each:

A different block producer

Proof of Work (pallet-pow). Substrate ships an actual PoW pallet. You replace pallet-aura with pallet-pow and add an offchain miner. Block production becomes "whoever finds a nonce satisfying the difficulty wins." Pros: no fixed authority set, permissionless. Cons: hardware-intensive, energy-hungry, throughput-limited, no good story for fast finality (GRANDPA still works, but is unusual atop PoW since the security models clash). Almost no production substrate chains use PoW; it's mostly an example for "yes, the modularity is real."

BABE from day one. BABE is what we move to in lesson 2. Skipping AURA and starting with BABE is reasonable if you know you want VRF-based slot selection from the start. The cost is configuration complexity: BABE needs pallet-session, pallet-authorship, and a longer config block. AURA is the lighter starting point; BABE is the destination.

Sassafras — the in-development successor to BABE. Not in stable2603 in production form. Skip until it stabilizes.

Manual seal (sc-consensus-manual-seal) — block production triggered by RPC call rather than by a slot beat. Useful for tests and local development. Used by Frontier-based chains for the EVM dev experience (a block is sealed per transaction). Not real consensus; not what you want for production.

PoA without rotation. A degenerate AURA: one authority forever. Useful for fully trusted setups (consortium chains, internal testnets). You'd configure MaxAuthorities = ConstU32<1> and never add a second validator.

A different finality model

No GRANDPA — probabilistic finality only. Drop pallet-grandpa. Block production happens, but blocks are only "probably final" — the longest chain wins, like Bitcoin. Pros: simpler, less network overhead. Cons: applications must wait N confirmations before treating a transaction as settled; reorgs are real; cross-chain bridges become harder to reason about. Almost no substrate chains ship without GRANDPA.

BEEFY in addition to GRANDPA. BEEFY (Bridge Efficient Encrypted Yielded-Finality) is a secondary finality protocol that produces proofs verifiable cheaply on Ethereum. Worth adding only if you intend to bridge to an Ethereum-compatible chain. Skipped for Substrate Tutorial because we have no bridging requirements.

Sub-second finality (Hotstuff, Tendermint, etc.). Some chains use BFT consensus that finalizes blocks before they're produced. Substrate doesn't ship these but they're available as forks. Trade-off: tighter finality at the cost of harder validator set rotation and less proven security analysis.

A different runtime composition

Add pallet-sudo from day one. This is the path 99% of substrate tutorials take. A single key (the sudo holder) can dispatch any extrinsic with root origin. It's a development-time convenience and a launch-time crutch. The trade-off: it's centralized by construction, and every chain that adopts "sudo until governance" eventually needs to remove it — which is harder than not adding it. We took the harder-now-easier-later path. If you're following this tutorial to build a real chain, your call: ergonomic dev experience vs. forcing yourself to design governance before launch.

Add pallet-utility and pallet-multisig. These are utility pallets — batch calls, multi-signature accounts — that don't change consensus but make the chain meaningfully more usable. We skipped them to keep this lesson focused; they'd add ~20 lines of config each and zero design complexity. Future lesson.

Different block time. We picked 6 seconds. Polkadot uses 6s. Kusama used 6s, now 2s on the Asset Hub. Bitcoin is 10 min. Ethereum is 12s. Fast blocks (1-3s) mean lower latency for user-facing apps but more state churn, larger archive nodes, and higher network overhead per unit of throughput. Slow blocks (30s+) reduce overhead but feel sluggish. 6s is a defensible middle ground.

Different token decimals. 12 decimals (UNIT = 1e12 planck). Polkadot uses 10. Ethereum-style chains use 18. Decimals affect display only; the runtime's Balance = u128 accommodates any choice. Picking unusual decimals (e.g., 8) creates user-facing friction with wallets. Conform unless you have a specific reason.

Different SS58 prefix. We use 42 (the generic substrate prefix). Production chains get assigned unique prefixes (Polkadot: 0, Kusama: 2). For testing or first-launch, 42 is fine. Apply for an SS58 prefix later if you want addresses that visually distinguish your chain.

A different dependency strategy

Pin to a tagged release. We pin to branch = "stable2603". An alternative is tag = "polkadot-stable2603.0" or a specific commit rev = "<hash>". Branch is more permissive (gets backports automatically); tag is more stable (a release point). For a tutorial repo, branch is fine — readers get whatever's current. For a production chain, pin to a specific tag or rev and bump deliberately.

Use the polkadot-sdk umbrella crate. Newer pattern: polkadot-sdk = { features = ["runtime-full"], ... } pulls in a curated subset. Smaller Cargo.toml, but features are coupled, and you don't see what's actually being used. We use individual crates because the granularity matters for learning. In production you'd often prefer the umbrella.

Trade-offs and caveats

A list of things we deliberately did not do, with one-line reasons:

  • No tests. Templates ship none either. We'll address in a later lesson if we add custom pallets that need them.
  • No benchmarks. All pallets use WeightInfo = () or the upstream SubstrateWeight. Real chains generate weights per their target hardware via frame-benchmarking.
  • No try-runtime support. Removed deliberately to keep the runtime API surface minimal. Re-add when runtime upgrades start happening.
  • No metadata-hash by default. Feature exists (metadata-hash), but it doubles wasm-build time, so it's opt-in.
  • No bootnodes in chain spec. You provide them via CLI flags. Production chains hard-code well-known bootnodes in the chain spec JSON.
  • MaxAuthorities = 32. Reasonable for a small chain. Cross this and the runtime needs an upgrade.
  • The chain has no app-level logic. It's plumbing. The reason you'd build a chain — your specific domain pallets — doesn't exist yet.

What's next

Lesson 2 swaps AURA for BABE. The motivation: AURA's authority set is fixed at genesis, and "round-robin" production is predictable. An adversary knows in advance which validator will author each block. BABE solves both: validators are chosen by VRF (verifiable random function) per slot, and pallet-session adds the ability to rotate the authority set per epoch. The same shift also adds pallet-session's session-key machinery, which lets validators rotate their consensus keys without restarting their nodes.

References


Next up: Lesson 2 — From AURA to BABE: randomness in the slot lottery