Skip to content

Latest commit

 

History

History
323 lines (223 loc) · 19.8 KB

File metadata and controls

323 lines (223 loc) · 19.8 KB

Lesson 4 — OpenGov: making the chain governable

Commit: 5232539Let the chain govern itself: OpenGov with conviction voting

Reading time: ~14 min.

Where we are. After lesson 3 we have a chain with real validator economics — staking, slashing, equivocation reporting — but no way to govern itself. pallet_staking::AdminOrigin = EnsureRoot<AccountId> is dead code: there's no sudo, no governance, no path from any extrinsic to a root origin. Slashes can't be cancelled, eras can't be forced, the runtime can't be upgraded on-chain. This lesson fixes that. Spec version 102 → 103.

Goal

The chain gains on-chain governance via Polkadot's Generation-2 system, OpenGov. Anyone holding bonded UNIT can submit a referendum into one of 5 tracks, each with its own approval/support curves and decision periods. Token holders vote with optional conviction: 0.1× weight with no lock, up to weight in exchange for locking their balance up to 32× longer. Approved referenda execute automatically via the scheduler.

The most consequential immediate effect: the EnsureRoot origin becomes reachable. Any call gated by EnsureRoot — including frame_system::set_code for runtime upgrades, and pallet_staking's admin actions — can now be dispatched via the Root track.

The runtime grows from 12 to 17 pallets.

Background

Two generations of substrate governance

Substrate's governance toolkit has evolved through two distinct designs:

  • Generation 1 — Council + Democracy (pallet-collective + pallet-democracy). A small elected council can propose changes directly and fast-track urgent ones; token holders vote on referenda from the proposal queue. This is what Polkadot used until 2023, what Kusama partially still uses, and what substrate/bin/node ships.

  • Generation 2 — OpenGov (pallet-referenda + pallet-conviction-voting). No council. Anyone submits a referendum into a track, where each track is a class of decisions with its own thresholds. Track-based separation lets you require, say, 75% Aye + 28-day confirmation for runtime upgrades while only 50% Aye + 24-hour confirmation for small treasury spends. Polkadot moved to this in 2023.

OpenGov is strictly more flexible than Gen-1, at the cost of a bigger configuration surface. For a chain being built today, it's the future-aligned choice.

The track concept

A track is an origin with a policy. Each track in OpenGov defines:

  • The origin it produces when a referendum on that track succeeds (e.g., Root, StakingAdmin, ReferendumCanceller).
  • Approval curve — what fraction of voters voting Aye is required as a function of time within the decision period. Our Root track (mirroring Westend's) starts at 100% Aye required at t=0 and decays to 50% at t=28d.
  • Support curve — what fraction of active issuance must vote at all (separately from Aye/Nay). Prevents a tiny but unanimous group from passing changes.
  • Decision period — how long voters have to vote.
  • Confirmation period — how long the approval+support must be sustained before the referendum confirms.
  • Min enactment period — delay between confirmation and execution.
  • Submission deposit — token amount the submitter locks; refunded on success, forfeit on rejection.
  • Decision deposit — additional amount locked when the referendum enters the deciding phase.
  • Max deciding — how many referenda on this track can be in the deciding phase simultaneously.

A referendum on track N produces an origin specific to track N. Pallets gate their extrinsics by origin: pallet_staking::cancel_deferred_slash requires AdminOrigin, which this commit sets to EitherOf<EnsureRoot, StakingAdmin> — so the Root track or the StakingAdmin track can call it, but no other track can. Other tracks produce different origins. This is how you scope governance: by mapping origins to which extrinsics they can dispatch.

Conviction voting

pallet-conviction-voting is the voting mechanism. When you vote, you choose:

  • Aye or Nay — direction
  • Balance — how much of your free balance to commit to this vote
  • Conviction — a multiplier from 0.1× ("None", no lock) up to ("Locked6x", 32× lock period)

The conviction multiplier amplifies your vote weight. conviction (Locked1x) means your vote counts at face value and your balance is locked for 7 days post-referendum. conviction means your vote counts 6× but the lock lasts 32× the base period (≈ 7.4 months for Substrate Tutorial's settings). This rewards committed voters and discourages whale-flash voting.

Preimage and scheduler — supporting infrastructure

Two adjacent pallets are wired alongside the governance pallets:

  • pallet-preimage — stores the actual call data that would dispatch if a referendum passes. Critical: referenda can be submitted by hash alone (cheap to submit). The full call data is uploaded separately, in advance. Anyone can upload a preimage; the uploader pays a per-byte deposit, refunded when the preimage is removed.
  • pallet-scheduler — schedules approved referenda for delayed execution after the enactment period. Also useful outside governance (e.g., scheduled validator-set rotations, periodic maintenance tasks), but in OpenGov it's the "actually dispatch this call N blocks from now" mechanism.

Both are mandatory for OpenGov. Without preimage, every referendum would have to inline its call data — expensive and impractical for large runtime upgrades. Without scheduler, there's nowhere to defer execution.

Why a custom origins pallet?

FRAME's runtime macro builds RuntimeOrigin and OriginCaller by aggregating #[pallet::origin] declarations from every pallet listed in #[frame_support::runtime]. To create new origin variants (one per non-Root track), you must put the #[pallet::origin] enum inside a #[frame_support::pallet] block, and register that pallet in the runtime's pallet list.

That's all pallet_custom_origins does — it's a registration-only pallet with no storage, no extrinsics, no events. Just a Pallet<T> marker, an empty Config trait, and a #[pallet::origin] enum with our 4 track origins. The Polkadot, Kusama, and Westend runtimes all do exactly this.

The path we chose

OpenGov over Gen-1, because Gen-1 is legacy — every modern chain is migrating to OpenGov, and starting on it avoids that migration.

No fellowship layer, meaning no pallet-whitelist and no pallet-ranked-collective. The fellowship is Polkadot's "technical expert body" that gates emergency whitelisted calls. For a chain with no clear technical body distinct from the broader community, the fellowship adds substantial complexity (member admission rules, rank requirements, fellowship-specific origins) for limited benefit. Add later if needed.

5 tracks, not 15+. The Westend relay runtime we mirror has 15+ — Root, WhitelistedCaller, plus various admin and treasury-spend tracks. We have 5: Root, StakingAdmin, GeneralAdmin, ReferendumCanceller, ReferendumKiller. Treasury-spend tracks appear when we add a treasury; the fellowship tracks appear only with a fellowship. Our 5 cover what we currently need.

Approval/support curves mirror Westend's exactly. Curve::make_reciprocal for Root approval (starts hard, eases over time); Curve::make_linear for support. The constants are tuned for token-distributed networks; we use the same. Easier to adjust later than to design from scratch.

Production-style time periods. Root track is 28 days. We use one set of numbers that match production rather than baking in dev-only shortcuts, so the periods you read here are the periods the chain enforces.

Code walkthrough

Five new pallets

Runtime declaration grows from 12 to 17 pallets (indices 12–16 in the runtime block):

#[runtime::pallet_index(12)] pub type Scheduler = pallet_scheduler;
#[runtime::pallet_index(13)] pub type Preimage = pallet_preimage;
#[runtime::pallet_index(14)] pub type ConvictionVoting = pallet_conviction_voting;
#[runtime::pallet_index(15)] pub type Referenda = pallet_referenda;
#[runtime::pallet_index(16)] pub type Origins = origins::pallet_custom_origins;

The last one is the custom origins pallet we wrote ourselves; the first four are stock polkadot-sdk pallets.

runtime/src/origins.rs — the registration pallet

#[frame_support::pallet]
pub mod pallet_custom_origins {
    use frame_support::pallet_prelude::*;

    #[pallet::config] pub trait Config: frame_system::Config {}
    #[pallet::pallet] pub struct Pallet<T>(_);

    #[pallet::origin]
    pub enum Origin {
        StakingAdmin,
        GeneralAdmin,
        ReferendumCanceller,
        ReferendumKiller,
    }

    // … `decl_unit_ensures!` macro generates `EnsureOrigin` impls for each variant
}

That's the whole file structurally — about 70 lines. The macro generates an EnsureOrigin impl per variant so you can write type CancelOrigin = ReferendumCanceller in another pallet's Config block. The 4 variants are exactly the non-Root track origins we'll define in tracks.rs.

runtime/src/tracks.rs — the track table

The bulk is the TRACKS_DATA constant array (one entry per track) and the TracksInfo impl that pallet-referenda consumes. The interesting parts:

Curves (matched from Westend's exactly):

const APP_ROOT: Curve = Curve::make_reciprocal(4, 28, percent(80), percent(50), percent(100));
const SUP_ROOT: Curve = Curve::make_linear(28, 28, percent(0), percent(50));
const APP_ADMIN: Curve = Curve::make_linear(17, 28, percent(50), percent(100));
const SUP_ADMIN: Curve = Curve::make_reciprocal(12, 28, percent(1), percent(0), percent(50));

make_reciprocal(delay, period, level, floor, ceil) builds a curve that starts at ceil (t=0) and decays toward floor, passing through level at time delay/period. make_linear(length, period, floor, ceil) is a straight line from ceil at t=0 down to floor at t=length.

Track entries (5 of them):

pallet_referenda::Track {
    id: 0,
    info: pallet_referenda::TrackInfo {
        name: s("root"),
        max_deciding: 1,
        decision_deposit: 100 * UNIT,
        prepare_period: 2 * HOURS,
        decision_period: 28 * DAYS,
        confirm_period: 24 * HOURS,
        min_enactment_period: 24 * HOURS,
        min_approval: APP_ROOT,
        min_support: SUP_ROOT,
    },
},

Durations are denominated in blocks. With 6 s blocks: DAYS = 14_400 blocks, HOURS = 600, MINUTES = 10. So the Root track's 28-day decision period is a real 28 days on-chain.

track_for() — the origin-to-track mapping:

fn track_for(id: &Self::RuntimeOrigin) -> Result<Self::Id, ()> {
    if let Ok(system_origin) = frame_system::RawOrigin::try_from(id.clone()) {
        match system_origin {
            frame_system::RawOrigin::Root => Ok(0),
            _ => Err(()),
        }
    } else if let Ok(custom_origin) = crate::origins::Origin::try_from(id.clone()) {
        match custom_origin {
            crate::origins::Origin::StakingAdmin => Ok(1),
            crate::origins::Origin::GeneralAdmin => Ok(2),
            crate::origins::Origin::ReferendumCanceller => Ok(3),
            crate::origins::Origin::ReferendumKiller => Ok(4),
        }
    } else {
        Err(())
    }
}

This is how pallet-referenda knows which track an origin belongs to when validating a referendum submission. The system Root origin maps to track 0; each of our custom origins maps to its own track.

runtime/src/configs.rs — the Config impls

Five new impl Config for Runtime blocks, alongside the existing 11. Notable bits:

Preimage uses a held deposit (the deposit is held via pallet-balances's hold mechanism, not transferred):

type Consideration = HoldConsideration<
    AccountId,
    Balances,
    PreimageHoldReason,
    LinearStoragePrice<PreimageBaseDeposit, PreimageByteDeposit, Balance>,
>;

PreimageHoldReason is a variant of the runtime-aggregate RuntimeHoldReason. We define:

pub const PreimageHoldReason: RuntimeHoldReason =
    RuntimeHoldReason::Preimage(pallet_preimage::HoldReason::Preimage);

The #[frame_support::runtime] macro auto-aggregates each pallet's HoldReason into the top-level RuntimeHoldReason. We just pick the right variant.

Scheduler allows direct scheduling from Root or the GeneralAdmin track:

type ScheduleOrigin = EitherOf<EnsureRoot<AccountId>, GeneralAdmin>;

This means schedule extrinsics (outside the OpenGov flow) require either root origin or a GeneralAdmin-track referendum. OpenGov's normal path bypasses this anyway — when a referendum confirms, pallet-referenda directly enqueues the call without going through ScheduleOrigin.

Conviction voting locks for a per-conviction multiplier:

parameter_types! { pub const VoteLockingPeriod: BlockNumber = 7 * DAYS; }

impl pallet_conviction_voting::Config for Runtime {
    type VoteLockingPeriod = VoteLockingPeriod;
    type MaxVotes = ConstU32<512>;
    // …
}

VoteLockingPeriod is the base; the actual lock for a Locked6x vote is 32 × VoteLockingPeriod ≈ 7.4 months.

Referenda wires everything together:

impl pallet_referenda::Config for Runtime {
    type Scheduler = Scheduler;
    type Currency = Balances;
    type SubmitOrigin = EnsureSigned<AccountId>;
    type CancelOrigin = EitherOf<EnsureRoot<AccountId>, ReferendumCanceller>;
    type KillOrigin = EitherOf<EnsureRoot<AccountId>, ReferendumKiller>;
    type Slash = ();    // slashed deposits burned (no treasury yet)
    type Votes = pallet_conviction_voting::VotesOf<Runtime>;
    type Tally = pallet_conviction_voting::TallyOf<Runtime>;
    type Tracks = TracksInfo;
    type Preimages = Preimage;
    // …
}

CancelOrigin = EitherOf<EnsureRoot<...>, ReferendumCanceller> means either a Root-track referendum or a ReferendumCanceller-track referendum can cancel an ongoing referendum. Same pattern for KillOrigin.

runtime/src/lib.rs — pallet declarations

The 5 new pallets get indices 12–16, and we declare two new top-level modules:

pub mod apis;
pub mod configs;
pub mod genesis_config_presets;
pub mod origins;     // new
pub mod tracks;      // new

Spec version 102 → 103 — the runtime's storage layout changed (referenda state, conviction-voting state, preimage state, scheduler state, custom origins) so a spec_version bump is mandatory. Existing dev chain databases must be purged before running this version.

Other paths we could have taken

Sudo back

Add pallet-sudo. A single key holder can dispatch any extrinsic with root origin. ~5 lines of config. The simplest path, and what 99% of substrate tutorials take. We avoided it back in lesson 1 specifically to force this conversation to happen properly. The trade-off: sudo is centralized by construction and removing it later is harder than not adding it.

When to pick: dev / staging / pre-launch / foundation-controlled chains where centralized admin is acceptable for a known duration.

Council only (pallet-collective standalone)

A fixed-size committee votes by majority on extrinsics. ~50 lines of config. No token-weighted voting. Multisig-with-on-chain-audit pattern.

When to pick: consortium chains, foundation networks, federated infrastructure where a known group of accounts is trusted to govern.

Generation-1: Council + Democracy

pallet-collective + pallet-democracy + pallet-scheduler + pallet-preimage. Council proposes; token holders vote; scheduler enacts. ~150 lines, 4 pallets. The Polkadot model from 2020–2023.

When to pick: if your user base is already familiar with Polkadot's Gen-1 governance UX (which has more public-facing tooling than OpenGov did initially). Substrate/bin/node uses this.

OpenGov + fellowship layer

What we built plus pallet-whitelist + pallet-ranked-collective. The fellowship is a rank-based collective that can whitelist specific call hashes for fast-track dispatch (bypassing normal track thresholds). Used in Polkadot for the Technical Fellowship.

When to pick: if you have a clear technical body distinct from broader token-holder community and want them to handle emergency upgrades or runtime fixes faster than full referenda allow.

Different track sets

Westend (the runtime we mirrored) has 15+ tracks. We picked 5. Notable tracks we skipped:

  • WhitelistedCaller — only meaningful with a fellowship layer
  • Treasury-spend tracks (Westend uses Treasurer / SmallSpender / MediumSpender / BigSpender / …) — only when a treasury exists
  • LeaseAdmin / AuctionAdmin — Polkadot-specific (relay-chain parachain leases)
  • FastGeneralAdmin — like GeneralAdmin but with shorter periods for less risky parameter changes

Each additional track costs ~30 lines of TRACKS_DATA plus an Origin variant. Add only when you have a category of decision that genuinely deserves different thresholds.

Trade-offs and caveats

  • Slash = () for rejected referenda. Submission deposits and decision deposits that are slashed (because the referendum was killed or otherwise forfeit) are burned. With a treasury they'd be routed there. No treasury yet.
  • No fellowship layer. No fast-track for emergency calls. If a critical bug surfaces, the only remedy is a Root-track referendum (28-day decision period in production). Polkadot mitigates this with the fellowship's ability to whitelist; we accept the wait.
  • Admin origins already route through the new tracks. This commit wires pallet_staking::AdminOrigin to EitherOf<EnsureRoot, StakingAdmin> and the scheduler's ScheduleOrigin to EitherOf<EnsureRoot, GeneralAdmin>, so staking admin and scheduling no longer require Root — a StakingAdmin- or GeneralAdmin-track referendum suffices (faster curves than Root's 28-day production decision period). The one still-bare origin is Preimage's ManagerOrigin = EnsureRoot, reachable only via the Root track.
  • Preimages can grow large, though the original noter can always reclaim theirs with unnote_preimage (and its deposit) once nothing references it.
  • Existing dev DBs need purging. Spec version 102 → 103 changes storage; older nodes can't sync newer state.

What's next

The natural next step is treasury. Treasury without governance is a write-only burn vault; with our OpenGov in place, the spending origins become real. Lesson 5 adds pallet-treasury, routes slashed funds, reward dust, and a share of transaction fees into it, and introduces two new tracks (Treasurer, BigSpender) whose origins authorize spends at different scales.

Beyond treasury, the design tree forks based on what your chain actually does. See 99-where-to-next.md for the broader roadmap — smart contracts, assets, NFTs, identity, custom pallets.

References


Next up: Lesson 5 — Treasury.