Skip to content

Latest commit

 

History

History
364 lines (257 loc) · 24.9 KB

File metadata and controls

364 lines (257 loc) · 24.9 KB

Lesson 3 — Validator economics: NPoS, slashing, and ImOnline

Commit: c246a27Give validators skin in the game: NPoS, ImOnline, and slashing

Reading time: ~15 min.

Where we are. From lesson 2: BABE + GRANDPA + pallet-session (with SessionManager = ()) + Babe/Grandpa RPC. The plumbing for validator rotation exists but is empty — same validators forever, no consequences for misbehavior. This lesson fills the plumbing in. Four new pallets, a real reward curve, equivocation slashing, liveness monitoring. Spec version 101 → 102.

Goal

The chain gains validator economics. Validators bond UNIT to be eligible; an on-chain Phragmén election picks up to MaxValidatorSet = 100 each era (the dev/local presets elect only the 1–2 genesis validators); rewards accrue per era following the Polkadot curve (2.5%–10% inflation, targeting 50% staked). Validators caught double-signing get slashed. Validators going offline lose rewards (and, with pallet-im-online, get marked as offline-offending too). Nominators can delegate stake to validators.

pallet_session::SessionManager flips from () to pallet_session::historical::NoteHistoricalRoot<Self, Staking>. pallet_babe::EquivocationReportSystem flips from () to the real EquivocationReportSystem<Self, Offences, Historical, ReportLongevity>. The BabeApi and GrandpaApi equivocation methods flip from None stubs to real implementations.

Background

Why staking exists

A chain that lets anyone be a validator has a Sybil problem: nothing stops one entity from spinning up thousands of validators and dominating the chain. Two answers:

  1. Permissioned set. Decide off-chain who can be a validator (AURA, AURA + GRANDPA, our lessons 1 and 2). Works if you trust a coordinator.
  2. Permissionless with economic security. Anyone can run a validator if they bond capital. Misbehavior loses capital. The more capital required, the harder a Sybil attack — but the more centralized the validator set in practice.

PoS is the second answer. Validators bond tokens (their own — "self-bond") and/or have tokens delegated to them by other holders ("nominators"). The total backing determines whether they make the active validator set each era. Rewards are paid proportional to backing. Slashes reduce backing. Both incentives align validators to behave: profit when you produce/finalize correctly, lose principal when you don't.

NPoS — Nominated Proof of Stake

Polkadot's variant. Distinguishing features:

  • Validators are picked by a Phragmén-style election each era. Not the top N by stake — the algorithm balances backing across validators to minimize variance and maximize the minimum-backed validator. This makes it harder for a small set of large nominators to control the active set.
  • Nominators can back up to 16 validators. Their stake is split across the validators they chose, weighted by the election algorithm.
  • Rewards and slashes are shared proportionally between validator and their nominators.

The election is non-trivial. On-chain Phragmén (what we use) runs the algorithm at every era boundary inside the runtime — bounded by the number of voters and targets. Polkadot uses multi-phase election: an off-chain solver submits a candidate solution, the chain verifies it, and a fallback on-chain solver runs if no off-chain submission lands in time. We skip multi-phase because at our scale (≤ 100 validators, ≤ a few hundred nominators) on-chain is fast enough.

Era, session, slot — three units of time, again

  • Slot — 6 seconds. BABE block production unit.
  • Session — 100 slots ≈ 10 minutes. BABE epoch = 1 session in our setup.
  • Era — 144 sessions ≈ 24 hours. Reward distribution unit. The election runs at the end of each era.

We picked era = 24h because it matches Polkadot's daily reward cycle, gives time for misbehavior reports to land, and rewards roughly 14,400 blocks at a time (144 sessions × 100 slots) — enough granularity that "what slashes apply to this era" is a meaningful query.

Equivocation — provable misbehavior

A validator equivocates when they sign two conflicting things at the same slot/round. For BABE: two different blocks at the same slot. For GRANDPA: two different votes at the same round. Both are detectable by other validators (just keep both signed messages). Both are non-attributable to honest software bugs (the same private key has to sign both, which would only happen if you tried to double-sign).

The flow:

  1. A validator observes another validator double-signing.
  2. They construct an EquivocationProof (the two conflicting signed messages).
  3. They submit it as an unsigned extrinsic.
  4. The runtime verifies the proof and routes it to pallet-offences.
  5. pallet-offences calls pallet-staking's offence handler.
  6. pallet-staking calculates a slash and applies it after SlashDeferDuration eras (27 in our setup; FRAME schedules the actual execution one era later, so ~28 eras of lead time for an admin origin to cancel a false positive — though at this lesson that origin, EnsureRoot, is not yet reachable; lesson 4's governance makes it so).

The slash fraction depends on how many validators are equivocating concurrently (Polkadot's formula: small for one validator, large if many at once — discouraging coordinated attacks).

Liveness — ImOnline

Equivocation is provable misbehavior. Liveness failure (validator goes offline) is harder: it's "I didn't see your block" rather than "here's proof you misbehaved." pallet-im-online solves this by requiring validators to send periodic heartbeat extrinsics (unsigned, signed with a dedicated ImOnline session key). Missing heartbeats trigger an UnresponsivenessOffence reported to pallet-offences, which can apply a small slash.

The slash for going offline is much smaller than for equivocation. Below a threshold (roughly 10% of the validator set offline in a session) the offline slash is exactly 0%; above it, it rises linearly with the number of offline validators up to a ~7% cap. Equivocation, by contrast, escalates quadratically with the number of simultaneous offenders. The intent is "incentivize uptime" not "destroy the offender."

What pallet-session::historical does

When an equivocation is reported, the report identifies the validator as it existed at the time of the offense — which may be sessions ago. The runtime needs to verify "yes, this AccountId was a validator at session N" even though session N is past and the validator set has rotated since.

pallet-session::historical keeps a Merkle-rooted history of past validator sets, with enough metadata to verify membership proofs. KeyOwnerProof = sp_session::MembershipProof is a proof against that history.

In lesson 2, we set KeyOwnerProof = sp_core::Void because we had no history to prove against. Here we flip it on.

The path we chose

NPoS + on-chain Phragmén + ImOnline + Polkadot reward curve. Real validator economics in the simplest viable shape:

  • NPoS, not self-bonded-only. Validators can be nominated by other token holders. The election balances stake across validators rather than just picking the richest.
  • On-chain election, not multi-phase. Simpler. Scales to ~1000 stakers. Multi-phase election is option C from the staking-scope discussion; we picked option B.
  • No bags-list, no nomination-pools. Both are scaling features. Skip until needed (and add additively when you do).
  • ImOnline for liveness, no authority-discovery. ImOnline runs over the normal block gossip; we don't yet need DHT-based peer discovery.
  • Polkadot curve for rewards. Reward curve definition (pallet-staking-reward-curve::build! macro) targeting 50% staked, 2.5% min inflation, 10% max.
  • 24-hour eras. 144 sessions/era at 10-min sessions.
  • 28-era bonding duration. ~28 days. Funds bonded for staking can't be withdrawn for 28 days after unbonding.
  • 27-era slash defer. Slashes are calculated immediately but execute ~28 eras later (SlashDeferDuration = 27, plus FRAME's +1) — allowing an admin origin to cancel false positives. At this lesson that origin is AdminOrigin = EnsureRoot, which nothing can reach yet (no sudo, no governance); lesson 4 wires it to EitherOf<EnsureRoot, StakingAdmin> so a StakingAdmin-track referendum can actually perform the cancellation.
  • All slashes burned, all reward remainders burned. No treasury wired up yet. Lesson 5 plumbs treasury and routes these sinks into it.

Code walkthrough

Six pallets are involved

#[runtime::pallet_index(6)]  pub type Staking = pallet_staking;
#[runtime::pallet_index(7)]  pub type Session = pallet_session;  // already present, reordered
#[runtime::pallet_index(8)]  pub type Historical = pallet_session_historical;
#[runtime::pallet_index(9)]  pub type Grandpa = pallet_grandpa;
#[runtime::pallet_index(10)] pub type ImOnline = pallet_im_online;
#[runtime::pallet_index(11)] pub type Offences = pallet_offences;

Four are new: Staking, Historical, ImOnline, Offences. Session and Grandpa moved indices (this is a pre-launch chain, so renumbering is free).

pallet_session_historical is not a separate crate — it's pallet_session::historical aliased via use pallet_session::historical as pallet_session_historical; so the #[frame_support::runtime] macro can register it as if it were.

The four flips that activate slashing

In configs.rs:

// pallet_babe::Config and pallet_grandpa::Config:
type KeyOwnerProof = sp_session::MembershipProof;            // was sp_core::Void
type EquivocationReportSystem =
    pallet_babe::EquivocationReportSystem<Self, Offences, Historical, ReportLongevity>;
                                                              // was ()

// pallet_session::Config:
type SessionManager = pallet_session::historical::NoteHistoricalRoot<Self, Staking>;
                                                              // was ()

// pallet_authorship::Config:
type EventHandler = (Staking, ImOnline);                      // was ()

These are the lines that turn lesson 2's empty machinery into real economic security. NoteHistoricalRoot<Self, Staking> means "use Staking as the new-session-validator-set-decider, and record the resulting set in the historical Merkle tree so future equivocation reports can prove membership."

pallet-staking::Config — the centerpiece

About 30 fields. The semantically interesting ones:

impl pallet_staking::Config for Runtime {
    type Currency = Balances;
    type SessionsPerEra = SessionsPerEra;          // 144
    type BondingDuration = BondingDuration;        // 28 eras
    type SlashDeferDuration = SlashDeferDuration;  // 27 eras
    type RewardRemainder = ();                     // burn
    type Slash = ();                               // burn
    type Reward = ();                              // minted, not from a pot
    type SessionInterface = Self;
    type EraPayout = pallet_staking::ConvertCurve<RewardCurve>;
    type ElectionProvider = onchain::OnChainExecution<OnChainSeqPhragmen>;
    type GenesisElectionProvider = onchain::OnChainExecution<OnChainSeqPhragmen>;
    type VoterList = pallet_staking::UseNominatorsAndValidatorsMap<Self>;
    type TargetList = pallet_staking::UseValidatorsMap<Self>;
    type NominationsQuota = pallet_staking::FixedNominationsQuota<16>;
    type MaxValidatorSet = MaxValidatorSet;        // 100
    type AdminOrigin = EnsureRoot<AccountId>;      // unreachable without sudo
    // …
}

Slash = () and RewardRemainder = (). These are OnUnbalanced adapters. () is the no-op: tokens vanish (burned). Substrate's ResolveTo<TreasuryAccount, Balances> would route them to a treasury account. We don't have a treasury, so they burn.

VoterList = UseNominatorsAndValidatorsMap<Self> and TargetList = UseValidatorsMap<Self>. These are the data adapters that the election provider walks. The plain UseValidatorsMap iterates the validators map; fine at our scale. At Polkadot's scale you'd use pallet-bags-list to make this O(active set) instead of O(all stakers).

AdminOrigin = EnsureRoot<AccountId>. This is the origin for force-unstake, cancel-deferred-slash, force-new-era, etc. EnsureRoot is reachable only via the root origin (RawOrigin::Root), which nothing on this chain can produce yet — there's no pallet-sudo and no governance. We have neither. So all AdminOrigin-gated extrinsics are dead code in Substrate Tutorial. This is an explicit trade-off documented in the README's caveats.

The reward curve

pallet_staking_reward_curve::build! {
    const REWARD_CURVE: PiecewiseLinear<'static> = curve!(
        min_inflation: 0_025_000,    // 2.5%
        max_inflation: 0_100_000,    // 10%
        ideal_stake: 0_500_000,      // 50%
        falloff: 0_050_000,          // how fast we move away from ideal_stake
        max_piece_count: 40,
        test_precision: 0_005_000,
    );
}

The build! macro is a proc-macro that runs at compile time and emits a static PiecewiseLinear curve object. The curve's interpretation: if 50% of total issuance is staked, inflation per era is at the max (10% annualized). As staked ratio moves away from 50% in either direction, inflation falls (toward 2.5% at the extremes). The falloff parameter controls how fast it falls.

This is Polkadot's exact curve. The economic intuition: incentivize ~50% staking. Lower than that and rewards rise to attract more stakers. Higher than that and rewards fall because we have enough security and shouldn't dilute the rest of supply.

EraPayout = pallet_staking::ConvertCurve<RewardCurve> wires the curve into Staking. At each era boundary, the era payout is computed from the curve and the actual staked ratio, then distributed.

The election provider

pub struct OnChainSeqPhragmen;
impl onchain::Config for OnChainSeqPhragmen {
    type Sort = ConstBool<true>;
    type System = Runtime;
    type Solver = SequentialPhragmen<AccountId, Perbill>;
    type DataProvider = Staking;
    type WeightInfo = frame_election_provider_support::weights::SubstrateWeight<Runtime>;
    type Bounds = ElectionBoundsOnChain;
    type MaxBackersPerWinner = ConstU32<256>;
    type MaxWinnersPerPage = MaxValidatorSet;
}

SequentialPhragmen is the Phragmén variant used here. The election runs at era boundary, takes the voter list (from Staking) and target list (validators), and produces an assignment of stake to validators that minimizes variance and maximizes the minimum backing.

ImOnline and the offchain unsigned-extrinsic glue

pallet-im-online submits heartbeats from validators' off-chain workers. Heartbeats are unsigned extrinsics (the validator's session key signs the call's payload, but the transaction itself isn't a normal signed transaction). For the runtime to support that, three traits must be implemented:

impl frame_system::offchain::SigningTypes for Runtime {
    type Public = <Signature as Verify>::Signer;
    type Signature = Signature;
}

impl<C> frame_system::offchain::CreateTransactionBase<C> for Runtime
where RuntimeCall: From<C>,
{
    type Extrinsic = UncheckedExtrinsic;
    type RuntimeCall = RuntimeCall;
}

impl<LocalCall> frame_system::offchain::CreateBare<LocalCall> for Runtime
where RuntimeCall: From<LocalCall>,
{
    fn create_bare(call: RuntimeCall) -> UncheckedExtrinsic {
        generic::UncheckedExtrinsic::new_bare(call).into()
    }
}

CreateBare is the trait that lets pallet-im-online construct heartbeat extrinsics; it's also what pallet-babe::EquivocationReportSystem and pallet-grandpa::EquivocationReportSystem use to submit equivocation reports. The three impls together are the "this runtime supports unsigned extrinsics from offchain workers" announcement.

Runtime API additions

In apis.rs:

// BabeApi — the stubs from lesson 2 are real now:
fn generate_key_ownership_proof(...) -> Option<...> {
    use codec::Encode;
    Historical::prove((sp_consensus_babe::KEY_TYPE, authority_id))
        .map(|p| p.encode())
        .map(sp_consensus_babe::OpaqueKeyOwnershipProof::new)
}

fn submit_report_equivocation_unsigned_extrinsic(...) -> Option<()> {
    let key_owner_proof = key_owner_proof.decode()?;
    Babe::submit_unsigned_equivocation_report(equivocation_proof, key_owner_proof)
}

// Same shape for GrandpaApi.

// New: StakingApi
impl pallet_staking_runtime_api::StakingApi<Block, Balance, AccountId> for Runtime {
    fn nominations_quota(balance: Balance) -> u32 {
        Staking::api_nominations_quota(balance)
    }
    fn eras_stakers_page_count(era: sp_staking::EraIndex, account: AccountId) -> sp_staking::Page {
        Staking::api_eras_stakers_page_count(era, account)
    }
    fn pending_rewards(era: sp_staking::EraIndex, account: AccountId) -> bool {
        Staking::api_pending_rewards(era, account)
    }
}

Historical::prove requires frame_support::traits::KeyOwnerProofSystem in scope — the trait that provides the prove method.

Genesis updates

const VALIDATOR_BOND: u128 = 100 * UNIT;

let stakers = initial_authorities
    .iter()
    .map(|x| (x.0.clone(), x.0.clone(), VALIDATOR_BOND, StakerStatus::<AccountId>::Validator))
    .collect::<Vec<_>>();

build_struct_json_patch!(RuntimeGenesisConfig {
    // …
    staking: StakingConfig {
        validator_count: initial_authorities.len() as u32,
        minimum_validator_count: 1,
        invulnerables,
        slash_reward_fraction: Perbill::from_percent(10),
        force_era: Forcing::NotForcing,
        stakers,
    },
});

Each genesis validator self-bonds 100 * UNIT and is marked invulnerable (protected from slashing). The slash_reward_fraction of 10% sets the pool of a slash that is paid out to reporters; the reporter share is half of that pool, so a reporter of a successful equivocation earns up to 5% of the slashed amount.

SessionKeys also grows a third field, im_online, which is a separate sr25519 key from BABE's. We derive it from the same Sr25519Keyring keypair for convenience in dev/local presets — different KeyTypeId, same underlying bytes.

Other paths we could have taken

Self-bonded staking only

Skip nomination entirely. Configure NominationsQuota = pallet_staking::FixedNominationsQuota<0> and you have a chain where the only "stake" is what each validator bonds themselves. Pros: simpler mental model; no nominator UX; no nomination pool considerations later. Cons: every validator must self-fund their security deposit, which limits decentralization to wealthy validators. Used by some appchains where validators are an explicit consortium.

No staking at all — PoA forever

Stay on lesson 2's configuration. The validator set is fixed at genesis (or rotated manually via a custom pallet you'd write). No rewards, no slashes. Trust the validators or don't run the chain. Workable for consortium chains, internal networks, sidechains backed by an external trust anchor. Removes about 70% of this lesson's complexity.

Multi-phase election (pallet-election-provider-multi-phase)

The Polkadot answer at scale. Off-chain workers compute candidate election solutions, submit them on-chain, the chain verifies and picks the best (with on-chain election as fallback). Pros: scales to 10,000+ stakers. Cons: substantial extra config (~80 lines), more on-chain state, more failure modes (what if no solution submitted?). Add when your active set or nominator count starts pushing 1000.

Bags-list (pallet-bags-list)

A sorted list of stakers in buckets. The election walks buckets top-down. Reduces election cost from O(N) to O(active_set + bag_count). Add when on-chain Phragmén starts taking too long. Independent of multi-phase — you can have one without the other.

Nomination pools (pallet-nomination-pools)

A delegation aggregator: small holders pool their stake under a single nominator (the pool), which then nominates validators. Useful when single-nominator minimum bonds are high relative to typical holder balances. Polkadot uses this. Most appchains don't. Add when your minimum economically-viable nomination is high enough to exclude small holders.

No liveness slashing

Skip pallet-im-online. Validators that go offline still don't earn rewards (built-in economic disincentive), but they aren't actively slashed. Simpler runtime, no offchain heartbeat machinery, no extra session key. Cons: longer offline events cost the chain finality progress with no proportional cost to the offline validator.

Real liveness slashing via authority-discovery

Skip pallet-im-online, use pallet-authority-discovery + a custom liveness detection. Polkadot uses both; we use only the first. Useful when validators are gossipping over DHT and offline can be inferred from peer connectivity rather than from explicit heartbeats. Adds DHT machinery; we don't need it at our scale.

Different inflation policy

We use the Polkadot reward curve (2.5%–10%, target 50% staked). Alternatives:

  • Constant per era. Fixed EraPayout returning a hardcoded payout regardless of staked ratio. Predictable but ignores demand for staking security.
  • Zero inflation. No new tokens minted. Validators are paid only out of transaction fees. Requires healthy fee volume to keep validators interested. Used by some governance-heavy chains.
  • Different curve targets. Target 30% staked, 70% staked, or any other. Lower target → less stake required to maximize inflation → easier to game; higher target → less circulating supply available for transactions.

Different era length

24 hours is what Polkadot uses. Tradeoffs:

  • Shorter era (e.g., 6 hours). Faster reward feedback, more rotation events per day, more election runs (more on-chain work). Good for testing.
  • Longer era (e.g., weekly). Less overhead but slower response to validator misbehavior, more capital locked in shorter unbonding cycles.

Slashes to treasury vs. burn

We burn. The alternative — Slash = ResolveTo<TreasuryAccount, Balances> — keeps slashed value in the system instead of destroying it, with the (presumed) governance system deciding what to do with it. Adds a moral hazard: governance can profit from slashing, which incentivizes false slashing votes. Polkadot routes slashes to treasury and accepts that risk; some chains burn to eliminate the incentive.

Trade-offs and caveats

  • AdminOrigin = EnsureRoot is unreachable without sudo/governance. Force-new-era, cancel-deferred-slash, force-unstake — all dead code. Acceptable for dev/staging; production needs sudo or governance to make them callable. Documented in README caveats.
  • Slashes and reward remainders are burned. No treasury. Lesson 5 wires treasury and routes these sinks into it.
  • Genesis validators are invulnerable. Alice and Bob (in dev/local) cannot be slashed. This is a development convenience; production genesis would mark a smaller set or none invulnerable.
  • MaxValidatorSet = 100. Hard cap. Larger sets require a runtime upgrade.
  • MaxNominators = 64 (in BABE/GRANDPA configs). Per-validator nominator cap. With on-chain election this is fine; multi-phase would push this much higher.
  • HistoryDepth = 84 eras. Old era state is pruned after this many eras. Reward claims must happen within 84 eras (84 days at our era length); after that they expire.
  • Spec version 101 → 102. Existing nodes need DB purge.

What's next

Lesson 4 addresses the elephant in the room: AdminOrigin = EnsureRoot is currently unreachable. With no sudo, no governance, and no other path to root origin, every admin-gated call in this lesson — cancel_deferred_slash, force_new_era, force_unstake — is dead code. The chain has economic security but no political authority to use it.

Lesson 4 adds OpenGov (Gov2): track-based referenda, conviction voting, a custom origins pallet, and the scheduler/preimage infrastructure. After it, EnsureRoot becomes reachable via the Root track, runtime upgrades become possible on-chain, and the staking admin operations become real options.

References


Next up: Lesson 4 — OpenGov: making the chain governable